Skip to content

audit-log (B): config_change reaches sys_audit_log — settings service dual-writes or reroutes (implementation decides), and settings-service.types.ts:16 becomes true #8145

Closed

Description

@hotlong

Sub-issue B of #7675, split by the triage seat (session session_01GiG1DfMysjbbFLZErAo93G, 2026-08-12) carrying the maintainer ruling of 2026-08-12 (comment 5261744983 on #7675). This card is the domain:services half.

Ruling carried (verbatim, binding — not re-adjudicable)

config_change(settings 服务已在审计、只是写进了 sys_setting_audit —— 双写或改道 sys_audit_log,实现时一并解决 settings-service.types.ts:16 文档契约与实际去向的矛盾,以实现定契约)。

The real decision inside the ruled direction (put here deliberately, not discovered mid-flight)

The ruling allows dual-write or reroute and says 以实现定契约 — the implementation decides. So this card must decide whether sys_setting_audit keeps its rows, then make settings-service.types.ts:16 state exactly what the implementation does. The identity seat's analysis on #7675 (comment 5264328866) flagged this as the load-bearing choice; weigh: existing consumers/views of sys_setting_audit (measure who reads it before dropping it), duplicate-row cost of dual-write, and the parent's defect that config_change filters and the config_changes list view are empty today.

Scope

  • Settings writes produce sys_audit_log rows with action: 'config_change' (emission point already located: settings-service-plugin.ts:315, which today inserts into sys_setting_audit with action set).
  • settings-service.types.ts:16 ("Emit sys_audit_log rows for every successful write.") becomes true as written, or is rewritten to match the decided behaviour.
  • Acceptance: the parent's reproduction inverts — PUT /api/settings/branding then filter {"action":"config_change"} returns the event. The config_changes list view starts showing rows.

Out of scope

  • ⛔ login/logout writers (sub-issue A, identity). ⛔ Enum retirement (sub-issue C). ⛔ objectui surfaces.

No Blocked-by: — config_change remains an enum member; independent of the retirement ordering.

Size/model suggestion: M, opus (carries a real design decision within the ruled direction).

Activity

  1. added theissue type on Aug 12, 2026
  2. self-assigned this
    on Aug 13, 2026
  3. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 12
    Session: session_01ARidKDYSCD56LaygrvDPnk
    Branch: claude/issue-8145-config-change-audit-log
    Worktree: objectstack-issue-8145
    Domain: domain:services
    File surface: packages/services/service-settings/src/**, packages/plugins/plugin-audit/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: #8026 (same service-settings write path) is MERGED (PR #8274, 02:2xZ) — this card was deliberately held back from the previous batch for same-file serial and is dispatched only now. No other in-flight claim touches service-settings or plugin-audit. #8103's read-only classifier also landed (PR #8261) and adds a module this card does not touch.

    Re-pricing after the blocker cleared — and one thing I owe on the record

    Standing discipline says the card ahead in a serial queue should be asked, at dispatch, whether its change makes the held-back card easier / harder / unnecessary / unaffected. I did not put that question in #8026's dispatch order. So I derived the answer myself from the merged diff rather than from its dev, which is weaker evidence and is flagged as such.

    Answer: unaffected in direction, but #8026 created a new case this card must now handle. It landed a fail-closed refusal on the settings write path — SettingsCryptoUnavailableError, enforced both in a setMany pre-flight pass and at the persist site. So there is now a settings write that is rejected before anything is persisted, which did not exist when this card was written. The contract line at issue says "for every successful write" — a refused write is not one, and must therefore emit no config_change row. ⛔ Do not discover this mid-implementation; pin it.

    Line numbers re-taken from the tree (the card's are stale)

    The card cites settings-service-plugin.ts:315. On current origin/main that line is inside the sys_secret store's delete. The actual sys_setting_audit insert is settings-service-plugin.ts:335 (writer doc-comment at :326), and the doc contract is settings-service.types.ts:15–17 ("Emit sys_audit_log rows for every successful write (encrypted values are masked)"), not :16 exactly. ⛔ Take every line number from the tree at implementation time.

    The consumer measurement, and the half of it I could not do

    The ruling permits dual-write or reroute, and the load-bearing choice is whether sys_setting_audit keeps its rows — which requires measuring who reads it. My grep over this repo finds only: the platform object definition and its generated translation bundles (platform-objects), and service-settings itself (manifest.test.ts, the writer). No consumer view or query in packages/, apps/, or examples/.

    ⚠️ That grep does not cover objectui, which is a separate repository and the likeliest home of a settings-audit view. ⛔ I have not searched it and I am not treating "I could not search it" as "it is clean." The dispatch requires that gap be closed or explicitly reported unclosed.


    Generated by Claude Code

  4. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8145,
      "status": "done",
      "branch": "claude/issue-8145-config-change-audit-log",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8288",
      "premise_still_valid": true,
      "summary": "The defect was one argument: SettingsServicePlugin.start() passed `undefined` for bindEngine's SettingsAuditSink slot — the slot documented since Phase 3 as the one writing the generic sys_audit_log — so every settings write landed only on sys_setting_audit and the config_change filter, the shipped config_changes list view and the console filter were permanently empty. Implemented DUAL-WRITE per the ruling's 以实现定契约: a new config-change-audit.ts sink writes one `action: 'config_change'` row per changed key (attributed on user_id+actor, tenant/organization_id stamped so RLS cannot hide it, digest-only — never a value), while sys_setting_audit keeps its rows unchanged; settings-service.types.ts now states exactly that. CONSUMER GAP CLOSED: searched the objectui checkout (HEAD 54d34d2, 1632 .tsx files) for sys_setting_audit/setting_audit/settingAudit across every tracked file — no hits, no settings-audit view in the console; combined with packages/apps/examples the only readers are the platform QA checklist (docs/qa/platform-checklist/areas/platform-core.json, which asserts a row per settings write) and manifest.test.ts, so a reroute would have broken a live reader and left a shipped-but-never-written table. No stored-data consequence: nothing is orphaned, no migration decided, packages/platform-objects untouched.",
      "tests": "Reverse verification, both directions predicted RED before running. (1) Production sources reverted to origin/main (config-change-audit.ts deleted, 4 files git-checkout'd), service-settings rebuilt, dogfood test kept: 3 failed | 1 passed — the parent's REST reproduction polls to exhaustion (total 0), the shipped view's OWN declared filter `expected 0 to be greater than or equal to 1`, and the dual-write case red on the ledger half while its sys_setting_audit row was present throughout (that presence is what makes the 'unchanged' half non-vacuous). (2) Surgical: only the bindEngine sink argument put back to `undefined` — 9 failed | 2 passed, every failure `AssertionError: expected [] to have a length of 1 but got +0`; the two refusal cases fail ONLY on their non-vacuity half, which is exactly what that guard exists to catch (their zero-length assertions stay green either way). Fix restored from the commit via `git checkout HEAD --` (no stash used), rebuilt, re-measured GREEN. After: `pnpm --filter @objectstack/service-settings test` → 'Test Files 23 passed (23) / Tests 451 passed (451)'; typecheck clean; new dogfood file 'Test Files 1 passed (1) / Tests 4 passed (4)'; the three neighbouring sys_audit_log/settings dogfood files 'Tests 11 passed (11)'; DOWNSTREAM sweep `pnpm --workspace-concurrency=2 --filter '...@objectstack/service-settings' typecheck` — all 10 consumers Done (prefix form = consumers, not dependencies). Gates: check:nul-bytes OK (7522 files) plus a grep -naP control-byte self-scan of all 8 changed files (exit 1, none); check:cross-package-test-inputs, check:docs-audit-scope, check:route-envelope, check:test-source-alias, check:type-source-resolution, check-changeset-fixed, check-changeset-no-major all OK. RE-DERIVED against the actual diff with scripts/pm/dispatch-gates.mjs and ran four families the prompt did not name — check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage — plus check:engine-double-contract (named as a convention risk): all OK. check:dev-prereqs FAILS in this worktree because 8 packages the diff never touches have no dist — a local build-state condition, not a diff finding (CI checks out fresh and builds).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Two things worth reading beyond the JSON

    1. The PM's #8026 mechanism assumption is FALSIFIED as stated — the refusal cannot reach the audit emitter on the plugin path, because it cannot fire there at all. SettingsServicePlugin.start() passes cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider() and secretStore: this.buildSecretStore(engine) — both unconditionally present whenever an engine exists — while assertEncryptionAvailable returns early on exactly that pair. So on any deployment where the settings service has an engine, every declared-encrypted key CAN be encrypted and SettingsCryptoUnavailableError never fires. It is reachable only on an engine-less service (lean kernels, control-plane mock) or one whose host binds its own engine without a provider.

    The pin did not become trivial, it became two pins:

    Both carry the non-vacuity half the dispatch warned about, and reverse verification proved that half is the one that bites: with the emitter unwired, those cases fail only on expected [] to have a length of 1.

    2. Declared file surface: one addition, deliberate. packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts is outside packages/services/service-settings/src/** + packages/plugins/plugin-audit/**. The card's acceptance is explicitly end to end and inverts the parent's reproduction, and only a booted stack with plugin-audit installed can see the real sys_audit_log, its real action enum and the shipped config_changes list view. service-settings must not depend on plugin-audit — that optionality is the whole reason the write is best-effort — so the package's own suite can only pin the wiring and the row shape at the engine seam, which it does. Production changes stayed inside service-settings/src/**; plugin-audit was not touched at all.

    Two small refactors inside the surface, both in service of the pins rather than of style: buildAuditWriter was lifted out of the plugin class as the exported buildSettingAuditWriter (so the dual-write pin exercises the writer the plugin builds, not a copy), and the service's audit.record call is now wrapped in try/catch — load-bearing rather than defensive, since sys_audit_log belongs to the optional plugin-audit and an unwrapped throw there would fail every settings write on a deployment without it.


    Generated by Claude Code

  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Review of PR #8288 — ACCEPT, pending CI convergence (⛔ not flipping ready until the gate jobs conclude)

    The gap I left open is closed, and the answer decided the card

    I dispatched this with the consumer measurement explicitly half-done: my grep covered this repo only, and objectui — the likeliest home of a settings-audit view — I could not search. The dev closed it: objectui at HEAD 54d34d2, 1632 .tsx files, searched for sys_setting_audit / setting_audit / settingAudit across every tracked file — no hits, no settings-audit view in the console.

    And the result inverted the naive reading. "Nobody reads it" sounds like a licence to reroute and drop the table. It is not, because the readers that do exist are: docs/qa/platform-checklist/areas/platform-core.json, which asserts a row per settings write, and manifest.test.ts. ⇒ A reroute would have broken a live reader and left a shipped-but-never-written table. Dual-write is chosen on a measurement, not a preference — which is exactly what 以实现定契约 asks for, and settings-service.types.ts now states precisely what was built.

    The root cause is better than the card's framing

    Not a missing feature — one argument. SettingsServicePlugin.start() passed undefined for bindEngine's SettingsAuditSink slot, the slot documented since Phase 3 as the one writing the generic sys_audit_log. So the sink was declared, wired nowhere, and every settings write landed only on sys_setting_audit. The config_change filter, the shipped config_changes list view and the console filter were permanently empty — a textbook declared-but-not-enforced, with the declaration sitting in the plugin's own signature.

    My mechanism assumption was FALSIFIED — and the correction is the good part

    I asserted that #8026's fail-closed refusal creates a settings write that is rejected before persistence, and that a config_change row must not be emitted for it. Wrong as stated: SettingsServicePlugin.start() passes cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider() and secretStore: this.buildSecretStore(engine) — both unconditionally present whenever an engine exists — and assertEncryptionAvailable returns early on exactly that pair. So SettingsCryptoUnavailableError cannot fire on the plugin path at all. It is reachable only on an engine-less service or a host binding its own engine without a provider.

    What makes this a good run rather than a mere correction: the dev separated what I wanted from what I said. Instead of dropping a pin whose mechanism didn't exist, it split it in two —

    ⛔ It did not quietly delete the instruction, and ⛔ it did not build a fixture to make my wrong mechanism appear reachable. Both is what a lesser run does.

    Reverse verification — the surgical lap is the one that counts

    Two directions, both predicted RED first. The decisive one is surgical: only the bindEngine sink argument put back to undefined ⇒ 9 failed | 2 passed, every failure expected [] to have a length of 1 but got +0. And the tell I asked for: the two refusal cases fail only on their non-vacuity half — their zero-length assertions stay green either way, which is precisely the trap the dispatch order named. The dual-write case is red on the ledger half while its sys_setting_audit row is present throughout, which is what makes the "unchanged" half non-vacuous rather than empty.

    Restored via git checkout HEAD --, ⛔ no stash — and notably this is the same workflow that produced #8280 on a sibling card this round; here it was done the safe way.

    Declared file-surface addition — one, justified, accepted

    packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts is outside the surface I declared. Accepted, because the card's acceptance criterion is explicitly end-to-end ("the parent's reproduction inverts; the config_changes list view starts showing rows"), and only a booted stack with plugin-audit installed can see the real sys_audit_log, its real action enum and the shipped view. Critically, service-settings must not depend on plugin-audit — that optionality is the whole reason the write is best-effort — so the package's own suite structurally cannot pin the acceptance criterion. Refusing the addition would have meant refusing the card's own acceptance test.

    ⛔ packages/plugins/plugin-audit not touched at all; ⛔ packages/platform-objects untouched, so my "that's domain:metadata, stop and report" boundary was never tested. Production changes stayed inside service-settings/src/**.

    Two in-surface refactors, both load-bearing rather than cosmetic: buildAuditWriter lifted out as the exported buildSettingAuditWriter so the dual-write pin exercises the writer the plugin actually builds, not a copy (anti-vacuity, correctly motivated); and audit.record wrapped in try/catch — necessary, not defensive, since sys_audit_log belongs to the optional plugin and an unwrapped throw there would fail every settings write on a deployment without it.

    Gates

    All named families green, plus five the dispatch list did not name, re-derived against the actual diff (check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, and check:engine-double-contract — which I did name as a convention risk after it bit a sibling card this round).

    ⚠️ check:dev-prereqs fails in the worktree because 8 packages the diff never touches have no dist — reported as a local build-state condition, not a diff finding. That is the honest call and it matches this round's other artifact-state lessons, but it is unverified until CI, which checks out fresh and builds. It is in this card's gate set, so I am treating CI's reading as the judgment and will confirm it before flipping ready.


    Generated by Claude Code

  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    ✅ CI converged — PR #8288 marked ready and auto-merge armed

    All gate jobs on head 2358b643 concluded success: ESLint, TypeScript Type Check, Test Core ×3, Dogfood Regression ×3, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Build Core, Check Changeset, Spec property liveness, Console Pin Freshness, Check PR Size, Check Documentation Links.

    Final path face — the declared breach is exactly one file, and the boundaries held

    Check Reading
    Path face 8 files, no docs/adr/** · .claude/skills/** · skills/** · content/docs/releases/** ⇒ normal landing path
    Outside declared surface exactly one — packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts, the addition that was declared and argued
    packages/plugins/plugin-audit not touched — I granted it in the surface and it turned out not to be needed
    packages/platform-objects not touched — the "that's domain:metadata, stop and report" boundary was never crossed

    Production changes are confined to service-settings/src/**. The breach is a test file that the card's own end-to-end acceptance criterion cannot be satisfied without.

    ⚠️ Correcting myself on check:dev-prereqs

    In my review I said I would treat CI's reading as the judgment on the locally-red check:dev-prereqs. That was wrong, and I am not letting it stand: lint.yml runs that gate as "Self-test the dev-prereqs gate (self-test half only, never the scan)". CI never runs the scan, so it neither confirms nor contradicts the dev's explanation — there is no CI reading to defer to.

    What actually stands is the dev's own account: 8 packages the diff never touches have no dist in that worktree, a local build-state condition. That is plausible, self-consistent, and matches this round's other artifact-state lessons (the #8235 false alarm and #8271 are the same family — a gate whose local reading depends on build state). It is not merge-blocking, since the scan is not a required check. But it is an unverified claim, not a verified one, and the distinction is exactly the kind this round has twice shown to matter.

    Landing note

    Fixes #8145, so this card closes on merge — correct, it is self-contained. Queue membership will be read off the enqueued event, ⛔ not off the enable_pr_auto_merge receipt, which came back with empty fields again and carries no information in either direction.


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions