Skip to content

fix(plugin-audit): an activity summary written right after a language change stores the new language - #24261

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24235-audit-write-locale-seam
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24235-audit-write-locale-seam

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24235

Clause-②: no

The activity summary writer in plugin-audit held the workspace locale for each tenant/user scope for a flat 30 s. Nothing retired it when localization.locale changed, and a sys_activity.summary line 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-only localeCache) | 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 changes localization.locale through PUT /api/settings/localization and writes within 30 s.

The defect, measured before the fix

A one-off rig (a scratch file, deleted, never committed) at main f5ef054a7: ObjectKernel + ObjectQLPlugin + the real SettingsServicePlugin + AuditPlugin over SQLite, with Date frozen.

step before (f5ef054a7) after (this branch)
audited write A, no setting yet Created Task "A", 2 settings reads same
control: audited write B0, no change between Created Task "B0", 0 settings reads same, 0 reads
settings.set('localization', 'locale', 'zh-CN', tenant ctx) reads back zh-CN, source tenant; resolveLocalizationContext answers zh-CN same
audited write B, right after the change Created Task "B" (old language, stored for good) 创建了 Task "B"
audited write C, clock moved +31 s 创建了 Task "C" same

The PM hypotheses, measured

  • Does the engine the audit plugin receives carry the write-epoch seam? Yes. ctx.getService('objectql') is the ObjectQL instance (objectql/src/plugin.ts registers this.ql), and writeEpoch.current is a number on it. So the core resolver does cache a success there.
  • Can the settings branch read through the core resolver with no memo of its own? It can, but its cache never hits for this caller. The engine bumps the epoch at the top of every insert, update and delete (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, and memoizes the locale lookup per tenant/user scope (hot-path guard) pins it. So the suggested route was not taken.
  • The route taken. The writer keeps one memo and retires it with the writes that can change its answer. These are any afterInsert / afterUpdate / afterDelete on sys_setting or sys_platform_setting, seen by the writer's own object-scoped hooks. SettingsService persists 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.
  • The host branch (AuditPluginOptions.getLocale via createHostLocaleReader) 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.ts is not touched.

What changed

  • packages/plugins/plugin-audit/src/audit-writers.ts: the memo entry carries the generation it was read at. retireHeldLocales advances the generation and is registered on the two settings objects for the three after-events, one string-scoped registration each. The handler re-checks ctx.object, because a dispatcher that ignored the scope would otherwise retire on every write. The AuditWriterOptions.getLocale doc 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 uses ObjectKernel + ObjectQLPlugin + AuditPlugin, SQLite, and a stand-in sys_setting under the real name; no settings service is registered, so the core resolver reads the store directly. Date is frozen, so nothing passes on the TTL. The case writes T1 in English and then a control write T2 with 0 sys_setting reads. An insert into sys_setting follows, and the next write stores 创建了 Task "T3". An update follows, and the next write stores Task「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, including sys_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: patch for @objectstack/plugin-audit.

Contract (Clause-② measured)

The built dist/index.d.ts of @objectstack/plugin-audit was built at f5ef054a7 and at this branch and then diffed. The only hunk is the JSDoc of AuditWriterOptions.getLocale, lines 234–237. No type, signature, export, option key or schema moves. installAuditWriters' options type and AuditPluginOptions keep their shape.

Tests and gates (head dc1de5306, after merging origin/main 8eccf519d)

  • 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; --listFiles names them.
  • Ablation (node scripts/ablation-replace.mjs, on the committed fix at 98e0d9fec): the anchor is hit.generation === generation &&, dropped from the read check, which restores the old TTL-only memo. The anchor went x1 to x0 and the blob f8bbd5f1b148 became 853a4ed4a906. 3 red, 100 green: the real-engine pin received Created Task "T3" and expected 创建了 Task "T3", and both writer cases went red. The restore was proven: blob == HEAD and git diff HEAD empty.
  • A second ablation, with a finding of its own. The first version also carried a map clear and a store guard. Dropping the store guard alone stayed green, because the read check alone already decides. Both extra guards were removed (98e0d9fec), leaving the one rule the ablation above turns red.
  • The derived gate list (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 at f5ef054a7, and --ran reconciles 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) and check-test-source-alias OK. On the pre-merge head 98e0d9fec, check:i18n and check:dual-build-cjs-loads first answered PREREQUISITE 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 at dc1de5306 above. Repo-wide scans (pnpm lint) and test:repo belong to CI; this diff touches no test:repo file.

Acceptance notes

  • Two comments in packages/core describe the old staleness as harmless. packages/core/src/security/resolve-authz-context.ts:1795–:1798 says 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–:428 makes 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/core is excluded from this card. Carrier: the domain:engine seat.
  • What the seam does not see. Two cases stay bounded by the 30 s TTL. The first is a localization write on another node: the core resolver gets a remote epoch bump through the authz.invalidated bridge, and this writer does not subscribe to the epoch. The second is the storage behind a host getLocale resolver. A SettingsService with 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.
  • Retirement is coarse. Any write to either settings store retires every held locale, whatever its namespace. A predicate update names no namespace the hook can read without fetching the row. Settings writes are rare, so the cost is one locale read per scope.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 7 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d.

⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 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 fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d3e07a47f3a9e717af470ee4f818fd0357b9f17 — the merge of head dc1de5306f04e897b3346573d944b523e9dee00e into base fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fb6fb24e2ffb2fee0cd94d7acaf7030a780e179d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T05:39Z · head dc1de5306f. The seat's own review at TIER_DEFAULT: --tier read no path-derived mandate, and the diff touches no published schema or governed text. It lands through the merge queue once every check on this head is green.

  • Shape: a draft PR to main. Its first line is Fixes #24235, and that is the only closing keyword in the body. Clause-②: no is at a line start in the body and in the changeset (@objectstack/plugin-audit patch). Card and PR assignee: marchtian. Not governed: check-governed-merges reads none of the 4 paths, and 286 changed lines ≤ 3000.
  • The premise, measured before the fix: on main f5ef054a7, with a real kernel and the real SettingsService, the audited write right after localization.locale was set to zh-CN stored the English line. The same write on this branch stores the Chinese one.
  • The fix, read by the seat in audit-writers.ts:
    • One rule retires a held write locale. Each entry is stamped with a generation, read before the lookup, and an entry with an older stamp is dead. A settings write that lands while a lookup is in flight therefore leaves the pre-write answer unheld.
    • The writer's own afterInsert / afterUpdate / afterDelete hooks advance the generation, scoped to sys_setting and sys_platform_setting. They carry the writer's packageId, so unregisterHooksByPackage still clears them on a hot reload. The handler re-checks ctx.object.
    • SettingsService persists through the engine, so a change made through the settings API is such a write, and so is a seeder's direct row write. The seat confirmed that only service-settings writes either store in packages/** source, so nothing writes them on a request's hot path, and the coarse any-namespace retirement costs one locale read per scope.
    • The 30 s TTL now bounds only what the hooks cannot see. Host-first precedence is untouched: audit-plugin.ts is not in the diff.
  • Pins: audit-write-locale-settings-seam.integration.test.ts runs on a real engine with the clock frozen, so no case passes by the TTL. An insert and then an update of the setting each reach the very next audited write (创建了 Task "T3", Task「T4」を作成しました). The control writes read the settings store 0 times. Two cases in audit-writers.test.ts cover both stores and the in-flight write. The dev's ablation (dropping the generation check) turned 3 cases red with the pin's own message, and the restore is blob-equal to HEAD.
  • Gates on this head: 34 checks succeeded and 6 were skipped by design (Build Docs, Console Pin Gate, Dogfood Regression Gate, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance: the roster set). There were no failures, and the required contexts are green. Locally the dev derived 68 families on this head, ran all 68, and all exited 0 (--ran: 0 NOT-MEASURED, 0 UNRUN).
  • Accepted deviations:
    • The suggested route was measured and not taken. Every audited write moves the engine epoch before the after-hook asks. So reading through the core resolver with no memo, or retiring on the epoch, costs 2 settings reads on every audited write. Retiring on writes to the two stores is the card's Wanted ("the same seam that retires the core resolver"), spelled at the writer.
    • The permanent pin uses a stand-in sys_setting. plugin-audit has no service-settings dependency, and adding one is outside the claim. The real-SettingsService reading was a one-off and is in the PR body.
    • origin/main was merged into the branch (8eccf519d), with no overlap with the diff. Tests and all 68 gates were re-run on the merge head.
  • Out-of-scope findings, with their carriers:
    • Two packages/core comments call the writer's staleness harmless and its memo TTL-only: resolve-authz-context.ts:1795–:1798 and resolve-authz-context.test.ts:422–:428. Carrier: the domain:engine seat, on its next touch of that file. The seat notes it on [PM seat] domain:engine — 🟢 os-project-manager #6367 at landing.
    • Acceptance notes: a localization write on another node, and a host getLocale resolver's own storage, stay bounded by the 30 s TTL. The writer does not subscribe to the remote invalidation bridge. That residual is narrower than the card's p3 window and no user has reached it.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 05:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 05:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 77e9f79 Oct 18, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24235-audit-write-locale-seam branch October 18, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants