Skip to content

plugin-audit: the activity summary writer holds the workspace locale for 30 s with no settings seam, so a write right after a language change is stored in the old language for good #24235

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): a product defect with a named site, reached once at a public door. Filed by domain:services seat 1 (seat post #6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH, from objectstack-ai/cloud#2988's dev report (os-dev-report on that card). ⛔ Not a claim. Grade and area:* are triage's.

Reader who acts: the domain:services seat (packages/plugins/plugin-audit), one dev round after triage grades it.

What happens (measured on main 6e43b7bc3, examples/app-showcase, tenancy single)

  1. PUT /api/settings/localization with { "locale": "zh-CN" }. The setting reads back zh-CN, source tenant.
  2. Within about 30 s, a human POST /api/v1/data/showcase_category stores the Discussion summary 「Created Category "…"」. A seed publish in the same window stores the same English line.
  3. After 30 s, the same writes store 「创建了 分类 "…"」.

A summary line is stored once, at write time, so the lines written in that window stay in the old language permanently.

Mechanism

  • packages/plugins/plugin-audit/src/audit-writers.ts (about :1278–:1293): resolveWriteLocale memoizes the host's getLocale(tenantId, userId) answer in localeCache, keyed tenantId|userId, for LOCALE_TTL_MS = 30_000. Nothing retires it when the localization setting changes.
  • The core resolver beneath it is retired. resolveLocalizationContext's success cache drops on the settings subscribe generation and on the engine write epoch. So this memo is the only layer that lags.

Wanted

  • The writer's held locale is retired by the same seam that retires the core resolver: a localization settings change, or the engine write epoch. A TTL then bounds only what that seam cannot see. Alternatively, the writer reads through the core resolver's cache and holds none of its own, if that fits.
  • A pin: after a settings change through the API, the very next audited write stores the new language. The control is a write with no settings change, which still hits the memo.

Dedupe

Dedupe words: resolveWriteLocale LOCALE_TTL_MS localeCache; activity summary stale locale after settings change; plugin-audit write locale memo invalidation.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p3 · area:i18n · domain:services · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T02:56Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Fresh read on origin/main cbfa5e4335: packages/plugins/plugin-audit/src/audit-writers.ts:1279–:1280 still hold LOCALE_TTL_MS = 30_000 and localeCache. Nothing retires the entry on a localization settings change.
    • Grade p3. Only lines written within 30 s after a workspace language change are stored in the old language. That is real but narrow, and no walk or user has hit it on its own: objectstack-ai/cloud#2988's dev found it while ruling out another cause. 「不严重,不处理」 sets the floor.
    • Scope when taken: the card's Wanted. The writer follows the core resolver's seam, or reads through it, and the pin uses the card's control. ⛔ No new setting.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-18T04:01Z
    Session: session_01Nk17Z26X5DB59iQztDBADH (the dispatching seat; the dev runs mode:subagent in this session's container and shares its session ID, so its identity is the branch)
    Account: marchtian (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-24235-audit-write-locale-seam
    Worktree: objectstack-issue-24235
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main f5ef054a7 or later; stop on a breach and explain it in the report):

    • packages/plugins/plugin-audit/src/audit-writers.ts: resolveWriteLocale's memo (LOCALE_TTL_MS / localeCache, about :1276–:1294) and its two callers' reads (about :2011 and :2358) only if the seam needs them.
    • packages/plugins/plugin-audit/src/audit-plugin.ts: the getLocale composition it hands installAuditWriters (about :369–:389), only if the seam is wired there.
    • packages/plugins/plugin-audit/src/** tests: the card's pin and its control, plus the existing audit-writers.test.ts cases this moves.
    • one changeset.
      Exclusions:
    • ⛔ packages/core is not touched. The core resolver's success cache (resolveLocalizationContext, resolve-authz-context.ts:2066) is read as it is. Its two comments that cite this memo as harmless (resolve-authz-context.ts:1796 and resolve-authz-context.test.ts:423) go to the report, carried to the domain:engine seat.
    • ⛔ No new setting, plugin option, environment variable or export (triage 6244948439: "⛔ No new setting"). installAuditWriters' options type and AuditPluginOptions keep their shape.
    • ⛔ The host getLocale option's precedence over the settings-derived locale stays as AuditPluginOptions.getLocale documents it.
      Container & model: S/M: one memo onto the core resolver's seam, one pin with its control, mode:subagent, model: opus (TIER_DEFAULT; --tier read no path-derived mandate)
      Clause-②: no. A held write locale retires sooner: no export, no option key, no schema change. The dev measures this on the built entry declarations and reports it.
      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 sits above and outlives | reached by any workspace that changes localization.locale through PUT /api/settings/localization and writes within 30 s; no user has reported it, and objectstack-ai/cloud#2988's dev found it while ruling out another cause
      Thread-read: 6244948439
      Serial constraints cleared: no open PR touches plugin-audit (4 open PRs read at this write). The 9 pm:dispatched claims read at this write name no plugin-audit file; runtime: one internal request-to-ExecutionContext resolver for the platform's five hand-rolled copies (rest, runtime, service-settings, service-storage, plugin-hono-server) — not a published export (ruling C′ on #23128, p3) #23217's slice (domain:cli) reads localization at REST's door, in rest-server.ts only. security(approvals, audit): a lookup target's title is resolved into the approvals inbox payload and the activity summary without asking the target's exposure (census rows 5-6 of #22661) #22738 (audit-writers.ts's resolveLookupTitles, domain:engine) is closed. The card still carries finding from its filing although triage graded it at 6244948439. That label is triage's to clear, and the seat leaves it. Scope: p3, under the maintainer's 「p3 也可以派」 (seat post §1).

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24235,
    "status": "done",
    "branch": "claude/issue-24235-audit-write-locale-seam",
    "pr": "#24261",
    "session": "session_01Nk17Z26X5DB59iQztDBADH — the dispatching seat's id (mode:subagent shares the parent's); identity is the branch",
    "premise_still_valid": true,
    "summary": "Premise held on main f5ef054. A one-off real boot (ObjectKernel + ObjectQLPlugin + real SettingsServicePlugin + AuditPlugin over SQLite, Date frozen) reproduced it: after settings.set(localization.locale = zh-CN) read back zh-CN (source tenant), the very next audited write stored "Created Task \"B\""; the same write on this branch stores "创建了 Task \"B\"". PM hypothesis measured: the engine the plugin receives is the ObjectQL instance and carries writeEpoch, so the core resolver does cache, but the engine bumps the epoch at the top of every insert/update/delete, so for an after-hook caller the core cache never hits (2 settings reads per call with an audited write between, 0 without). Reading through the core resolver with no memo, or retiring on the epoch itself, would put 2 settings reads on every audited write, so the suggested route was not taken. Route taken in audit-writers.ts only: the writer keeps one memo, each entry stamped with a generation read before the lookup, and its own object-scoped afterInsert/afterUpdate/afterDelete hooks on sys_setting and sys_platform_setting advance the generation (the handler re-checks ctx.object). That covers the settings API path (SettingsService persists through the engine) and the direct-row path a seeder takes, the two writes that retire the core cache, and a write that lands while a lookup is in flight. The TTL now bounds only the host getLocale branch (no seam to see) and a peer node's write. Host-first precedence is untouched (audit-plugin.ts not edited). No export, option key or signature moves: the built dist/index.d.ts diff vs f5ef054 is the AuditWriterOptions.getLocale JSDoc only, so Clause-②: no holds, and the line is in the PR body and the changeset.",
    "tests": "HEAD dc1de53 (after merging origin/main 8eccf51): pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 → "Test Files 56 passed (56) · Tests 894 passed (894)"; pnpm --filter @objectstack/plugin-audit typecheck → exit 0, "check:test-typecheck: OK … 0 file(s) / 0 error(s)"; tsc -p tsconfig.test.json --listFiles names both edited test files. New pins: audit-write-locale-settings-seam.integration.test.ts (real engine; T1 English, control T2 with 0 sys_setting reads, insert → T3 "创建了 Task \"T3\"", update → T4 "Task「T4」を作成しました", T5 held with 0 reads) and two cases in audit-writers.test.ts (each store incl. sys_platform_setting with a no-write control; a write landing mid-lookup). Ablation via scripts/ablation-replace.mjs on the committed fix 98e0d9f: dropped hit.generation === generation && (the old TTL-only memo); anchor x1→x0, blob f8bbd5f1b148→853a4ed4a906; 3 failed | 100 passed (pin: expected "创建了 Task \"T3\"", received "Created Task \"T3\""); restored: blob == HEAD, git diff HEAD empty. Direction observed: turned red, as expected. Subject resolves from plugin-audit src (relative import), so no dist rebuild was needed for the legs. An earlier ablation of a separate store guard stayed GREEN (3 overlapping guards); the redundant guards were removed in 98e0d9f, leaving the one rule that ablation turns red.",
    "gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at dc1de53, merge base 8eccf51: 68 commands, identical to the seat's lead. All 68 run at dc1de53, each exit captured before any pipe: 68 × exit 0. --ran (exit codes recorded) → "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero". Sample verdicts: "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)", "check-test-source-alias OK". On the pre-merge head 98e0d9f check:i18n and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, closure unbuilt); after the gate's own prerequisite build they passed, and the final run is the dc1de53 one above. CI on PR #24261 head dc1de53, read once at report time: Build Core queued; Test Core (1/6…6/6) queued; Type Check · workspace / source gates / consumer gates / debt ledger in_progress; Lint · ESLint / repo & tooling gates / docs & code guards / CI, release & engine gates / PM dispatch-gates / scoped lanes in_progress; Governed Surface Queue Guard in_progress; Check Changeset in_progress; Dogfood Regression Gate queued; Packed-tarball smoke skipped; "No other open PR may claim the same issue" success; "Part-of PR must not also close its card" success. Not waited on (CI convergence is the PM's). test:repo: not touched by the diff, declared to CI. pnpm lint: CI-owned.",
    "mcp_calls": "0 — no MCP GitHub tool used",
    "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches through scripts/pm/with-fleet.sh --via dispatch, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24261/assignees ["marchtian"], body read back identical (9767 bytes sent, 9767 stored); no labels (skip-changeset does not apply: plugin-audit publishes and the PR carries a patch changeset); (2) this os-dev-report → POST /repos//issues/24235/comments. git push ×5 (not REST). Reads: REST GETs for the card, its comments, the PR and its check-runs.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: domain:engine seat · noted, not filed — packages/core/src/security/resolve-authz-context.ts:1795-1798 says for the audit writer "a stale locale in a log line for up to 30s costs nothing observable", and packages/core/src/security/resolve-authz-context.test.ts:422-428 repeats it ("audit-writer's staleness tolerance"). The card shows it was observable (a summary line is stored once), and after this PR both comments also mis-describe the writer's memo as TTL-only. Comment drift only; packages/core is excluded by the claim. Recorded in the PR's Acceptance notes.",
    "carrier: 承接者:无 · Acceptance notes only — what the writer's seam cannot see stays TTL-bounded (30 s): a localization write on another node (the core resolver gets a remote epoch bump through the authz.invalidated bridge, and the writer does not subscribe to the epoch) and a host getLocale resolver's own storage. A SettingsService with no engine bound writes nothing the writer sees, but no stock composition reaches it (writes are refused while engineBindPending; no engine means no audit writer)."
    ],
    "files_changed": [
    "packages/plugins/plugin-audit/src/audit-writers.ts",
    "packages/plugins/plugin-audit/src/audit-writers.test.ts",
    "packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts",
    ".changeset/24235-audit-write-locale-seam.md"
    ],
    "line_budget": {
    "additions": 278,
    "deletions": 8,
    "files": 4,
    "measured": "git diff --numstat 8eccf51..dc1de53"
    },
    "deviations": [
    "Route: the PM's suggested route (settings branch reads through the core resolver, no memo) was not taken. Measured, it costs 2 settings reads per audited write, because every audited write moves the engine epoch before the after-hook asks. The card's Wanted alternative ("reads through … if that fits") does not fit for the same reason.",
    "Seam spelling: the card names "a localization settings change, or the engine write epoch". The writer retires on engine writes to the two settings stores, seen by its own hooks, not on the epoch counter (which retires on every write) and not on SettingsService.subscribe (the writer has no settings handle, and the claim forbids a new option). The API path is covered because SettingsService persists through the engine; that was measured end to end with the real SettingsService in the one-off rig.",
    "Pin fidelity: the permanent pin uses a stand-in sys_setting and the core resolver's direct read, not the real SettingsService. plugin-audit has no service-settings dependency, and package.json / pnpm-lock.yaml are outside the claimed file surface. The real-SettingsService before/after reading is a one-off scratch test (relative imports of service-settings and platform-objects src), deleted and never committed.",
    "Merged origin/main (8eccf51, 3 commits, no overlap with the diff) before opening the PR, per AGENTS.md §10, as merge commit dc1de53 with git's default message (no trailer pair). Tests, typecheck and all 68 gates were re-run on dc1de53.",
    "Two commands ran outside the verify lock or wastefully. tsc --noEmit -p tsconfig.test.json --listFiles (about 20 s) ran unlocked. One stray pnpm --filter @objectstack/plugin-audit test -- ran under the lock with its output discarded, and it is not used as evidence.",
    "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer. The commits carry AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), and the PR body ends with AGENTS.md's session-URL footer.",
    "Reverted before commit: a JSDoc edit to AuditPluginOptions.getLocale in audit-plugin.ts. That text is outside the claimed surface for that file, and the doc stays accurate as it is.",
    "The checkout is shallow (--is-shallow-repository true). No ancestry negative was relied on, and dispatch-gates derived the merge base without complaint."
    ]
    }

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24261 comment 6246187543 · domain:services seat 1 (#6021) · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T05:40Z. Head dc1de5306f; the seat's own review at TIER_DEFAULT (Clause-②: no, no schema or governed face). It lands through the merge queue, and every check on its head is green. Fixes #24235 closes this card at the merge.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T06:03Z.

    Landed: PR #24261 (Fixes #24235) merged through the merge queue as 77e9f79fbc at 2026-10-18T06:02Z. That commit is on origin/main (git merge-base --is-ancestor exit 0; control 03ac4eca21 exit 0, negative control PR head dc1de5306f exit 1, as a squash). All four files are byte-identical to the PR head dc1de5306f. ACCEPT 6246187543.

    Probe on origin/main Reading
    the generation a held write locale is stamped with audit-writers.ts:1307 (let localeGeneration = 0)
    the two stores whose writes retire it audit-writers.ts:1336 (sys_setting, sys_platform_setting)
    the writer's own after-hooks on them audit-writers.ts:1373, under the writer's packageId

    What a workspace now sees: the audited write right after a localization.locale change stores its activity summary in the new language. A write with no settings change between it and the last one is still answered from the held locale, without a settings read. The 30 s bound stays only for a host getLocale resolver's own storage and for a settings write on another node.

    Still open, with its carrier: two packages/core comments (resolve-authz-context.ts:1795–:1798, resolve-authz-context.test.ts:422–:428) still describe the writer's staleness as harmless and its memo as TTL-only. They go to the domain:engine seat (#6367) for its next touch of that file.

    The card closed completed at the merge. pm:dispatched is cleared and the assignee released in this act.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions