Repository navigation
fix(plugin-audit): an activity summary written right after a language change stores the new language - #24261
Conversation
…rite The activity summary writer held the workspace locale per tenant/user scope for a flat 30 s, with nothing retiring it when the localization setting changed. A summary line is stored once, so writes in that window kept the old language permanently. The held answer is now retired by any write to sys_setting or sys_platform_setting through the engine (the writer's own object-scoped after-hooks), with a generation counter taken before the lookup so an in-flight read cannot repopulate a stale answer. The TTL stays as the bound for what that seam cannot see: a host resolver's own storage and a peer node's write. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…a changeset A settings-store write between two audited writes: the second one stores the new language. The control is a write with no settings change, answered from the held locale with no settings read. Two writer cases cover the global rung's own store and a write landing while a lookup is in flight. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…e locale The memo carried three overlapping guards (a map clear, a store guard and a generation check on read); an ablation of the store guard stayed green, since the read check alone already decides. Keep that one rule, the core resolver's: an entry stamped with an older generation is dead. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…dit-write-locale-seam
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5d3e07a47f3a9e717af470ee4f818fd0357b9f17 && git checkout 5d3e07a47f3a9e717af470ee4f818fd0357b9f17
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d dc1de5306f04e897b3346573d944b523e9dee00e && git checkout -B drift-repro fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d && git merge --no-ff dc1de5306f04e897b3346573d944b523e9dee00e
node scripts/docs-audit/affected-docs.mjs --json fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d
|
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24235
Clause-②: no
The activity summary writer in
plugin-auditheld the workspace locale for each tenant/user scope for a flat 30 s. Nothing retired it whenlocalization.localechanged, and asys_activity.summaryline is stored once, at write time. So every audited write made in the 30 s after a language change stored the old language permanently. This PR retires the held locale when either settings store is written through the engine. The 30 s TTL stays only as the bound for what that seam cannot see.Responsibility:
plugin-audit's own writer (resolveWriteLocale's TTL-onlylocaleCache) | the platform path is the core resolver's success cache, retired by the settings generation and the engine write epoch, which the writer's memo sat above and outlived | reached by any workspace that changeslocalization.localethroughPUT /api/settings/localizationand writes within 30 s.The defect, measured before the fix
A one-off rig (a scratch file, deleted, never committed) at
mainf5ef054a7:ObjectKernel+ObjectQLPlugin+ the realSettingsServicePlugin+AuditPluginover SQLite, withDatefrozen.f5ef054a7)Created Task "A", 2 settings readsCreated Task "B0", 0 settings readssettings.set('localization', 'locale', 'zh-CN', tenant ctx)zh-CN, sourcetenant;resolveLocalizationContextanswerszh-CNCreated Task "B"(old language, stored for good)创建了 Task "B"创建了 Task "C"The PM hypotheses, measured
ctx.getService('objectql')is theObjectQLinstance (objectql/src/plugin.tsregistersthis.ql), andwriteEpoch.currentis a number on it. So the core resolver does cache a success there.executeWithMiddleware), and an audited write is one. By the time the writer's after-hook asks, the epoch has always moved. Measured on the same rig: two calls with no write between cost 2, then 0 settings reads; with one audited write between, the next call costs 2 again. Reading through, or retiring the writer's memo on the epoch itself, would add the full settings read (sys_platform_setting+sys_setting) to every audited write. The memo was written to keep that read off the hot path, andmemoizes the locale lookup per tenant/user scope (hot-path guard)pins it. So the suggested route was not taken.afterInsert/afterUpdate/afterDeleteonsys_settingorsys_platform_setting, seen by the writer's own object-scoped hooks.SettingsServicepersists through this engine, so a change through the settings API is one of them. A direct row write, such as a seeder's, is the other, and it emits no settings event at all. These are the same writes that retire the core resolver's cache: its settings generation (the API path) and its epoch backstop (the direct path). One rule decides: an entry stamped with an older generation is dead. The generation is read before the lookup and stamped on its answer, as the core resolver stamps its epoch, so a settings write that lands while a lookup is in flight cannot leave the pre-write answer held.AuditPluginOptions.getLocaleviacreateHostLocaleReader) keeps the TTL-bounded memo. A host resolver offers no seam the writer can see, so the TTL is its only bound, as the card's Wanted says. A settings-store write also retires a held host answer. That costs one extra host call and changes no answer. Host-first precedence is unchanged:audit-plugin.tsis not touched.What changed
packages/plugins/plugin-audit/src/audit-writers.ts: the memo entry carries the generation it was read at.retireHeldLocalesadvances the generation and is registered on the two settings objects for the three after-events, one string-scoped registration each. The handler re-checksctx.object, because a dispatcher that ignored the scope would otherwise retire on every write. TheAuditWriterOptions.getLocaledoc now says when a held answer ends.packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts(new): the card's pin on a real engine. It usesObjectKernel+ObjectQLPlugin+AuditPlugin, SQLite, and a stand-insys_settingunder the real name; no settings service is registered, so the core resolver reads the store directly.Dateis frozen, so nothing passes on the TTL. The case writes T1 in English and then a control write T2 with 0sys_settingreads. An insert intosys_settingfollows, and the next write stores创建了 Task "T3". An update follows, and the next write storesTask「T4」を作成しました. A last unchanged write is held again, with 0 reads.packages/plugins/plugin-audit/src/audit-writers.test.ts: two writer cases for what the real-engine pin cannot reach. The first covers each store, includingsys_platform_setting, the global rung, with the no-write control. The second covers a settings write landing while a lookup is in flight..changeset/24235-audit-write-locale-seam.md:patchfor@objectstack/plugin-audit.Contract (Clause-② measured)
The built
dist/index.d.tsof@objectstack/plugin-auditwas built atf5ef054a7and at this branch and then diffed. The only hunk is the JSDoc ofAuditWriterOptions.getLocale, lines 234–237. No type, signature, export, option key or schema moves.installAuditWriters' options type andAuditPluginOptionskeep their shape.Tests and gates (head
dc1de5306, after mergingorigin/main8eccf519d)pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2: 56 files, 894 passed.pnpm --filter @objectstack/plugin-audit typecheck: exit 0. The test layer compiles both edited test files;--listFilesnames them.node scripts/ablation-replace.mjs, on the committed fix at98e0d9fec): the anchor ishit.generation === generation &&, dropped from the read check, which restores the old TTL-only memo. The anchor went x1 to x0 and the blobf8bbd5f1b148became853a4ed4a906. 3 red, 100 green: the real-engine pin receivedCreated Task "T3"and expected创建了 Task "T3", and both writer cases went red. The restore was proven: blob == HEAD andgit diff HEADempty.98e0d9fec), leaving the one rule the ablation above turns red.dispatch-gates.mjs --commands, 68 commands) was run at this head: 68 of 68 exit 0. That list is identical to the seat's lead atf5ef054a7, and--ranreconciles it as 68 derived, 68 run, 0 NOT-MEASURED and 0 UNRUN, a derived zero because every line records its exit code. Among the verdict lines:check-i18n-bundles: OK (10 package(s) — all bundles in sync,check:dual-build-cjs-loads — 111 published require entry point(s) across 69 package(s) load,check-engine-double-contract: OK — 1022 pinned,check-nul-bytes: OK (scanned 12130 text file(s)andcheck-test-source-alias OK. On the pre-merge head98e0d9fec,check:i18nandcheck:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3) because their build closure was missing. They passed once the gate's own prerequisite build ran, and both were re-run atdc1de5306above. Repo-wide scans (pnpm lint) andtest:repobelong to CI; this diff touches notest:repofile.Acceptance notes
packages/coredescribe the old staleness as harmless.packages/core/src/security/resolve-authz-context.ts:1795–:1798says that for the audit writer "a stale locale in a log line for up to 30s costs nothing observable".packages/core/src/security/resolve-authz-context.test.ts:422–:428makes the same point ("audit-writer's staleness tolerance"). A summary line is stored once, so the staleness was observable. Both comments also call the writer's memo a TTL-only cache, which is no longer true.packages/coreis excluded from this card. Carrier: thedomain:engineseat.localizationwrite on another node: the core resolver gets a remote epoch bump through theauthz.invalidatedbridge, and this writer does not subscribe to the epoch. The second is the storage behind a hostgetLocaleresolver. ASettingsServicewith no engine bound writes nothing the writer can see, but no stock composition reaches that: the service refuses writes until it is bound (engineBindPending), and the audit writer is not installed without an engine.Generated by Claude Code