Skip to content

fix(service-settings): settings writes reach sys_audit_log as config_change (#8145) - #8288

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8145-config-change-audit-log
Aug 13, 2026
Merged

fix(service-settings): settings writes reach sys_audit_log as config_change (#8145)#8288
os-zhuang merged 2 commits into
mainfrom
claude/issue-8145-config-change-audit-log

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8145

PUT /api/settings/branding, then $filter={"action":"config_change"}total 0. That is #7675 step 2, and it held for the whole life of that enum member: the declared config_change action, the shipped config_changes list view and the console filter that offers the value were three surfaces advertising a class of audit event the platform never wrote. Settings changes were audited — into sys_setting_audit, action: 'set' — and nowhere else, while settings-service.types.ts promised sys_audit_log rows "for every successful write".

The cause was one argument

SettingsAuditSink is the slot documented since Phase 3 as the one that writes the generic ledger. It is SettingsService.bindEngine's second parameter, and SettingsServicePlugin.start() passed undefined for it. Nothing else was missing — the service called the sink on every write, the enum declared the value, the view filtered on it.

The decision the ruling left open: dual-write

The 2026-08-12 maintainer ruling on #7675 permits 双写 or 改道 and leaves the choice to the implementation (以实现定契约). sys_setting_audit keeps its rows, measured rather than assumed:

  • It has live readers.docs/qa/platform-checklist/areas/platform-core.json asserts a sys_setting_audit row per settings write as shipped platform behaviour, and manifest.test.ts pins the object's registration.
  • The objectui gap the dispatch flagged is CLOSED. Searched the sibling checkout (HEAD 54d34d2, 1632 .tsx files) for sys_setting_audit / setting_audit / settingAudit across every tracked file: no hits. There is no settings-audit view in the console. Combined with packages/, apps/, examples/, the only readers are the two above.
  • The rows are complements, not duplicates.sys_setting_audit holds namespace, key, scope, old_hash/new_hash, source, encrypted, reason — none of which has a column on sys_audit_log; the generic ledger answers "who changed platform configuration, when" across subsystems, which a per-namespace table cannot.
  • A reroute would leave a shipped-but-never-written table — the same defect class the ruling condemns (审计面宁窄勿谎) — and retiring it is packages/platform-objects surface plus a stored-row question this card is not the place to decide.

Duplicate-row cost is bounded and deliberately accepted: one extra row per changed key per settings write. These are admin-rate operations, nothing like the per-tick/per-chunk writers ADR-0057 D5 excluded from auditing, and sys_audit_log carries its own retention so growth is policy-capped.

What lands on the ledger

One row per changed key: action: 'config_change', object_name: 'sys_setting', record_id: null (a settings row is keyed on the composite (namespace, key, scope, user_id) — the same honest shape plugin-auth's run-level import row uses), attributed on both user_id and actor, and stamped with the caller's tenant plus organization_id where the deployment declares that column — without which RLS hides every row and leaves the view exactly as empty as the defect. metadata carries namespace/key/scope/encrypted; new_value carries a digest, never a value.

Best-effort by construction: sys_audit_log belongs to the OPTIONAL @objectstack/plugin-audit, so on a deployment without it the insert throws on every settings write. The sink swallows and reports once per process, and the service's own sink call is now wrapped for the same reason — the guard is what protects a host-supplied sink from taking the write path down.

A refused write emits nothing

A write refused before anything is persisted is not a successful one, so neither ledger records it. Pinned for the anonymous deny (403 SETTINGS_FORBIDDEN) and the env-pinned key (409 SETTINGS_LOCKED) — both code and status — and separately for #8026's SETTINGS_CRYPTO_UNAVAILABLE.

⚠️One dispatch assumption falsified.#8026's fail-closed refusal is not reachable through the plugin's own wiring: start() passes cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider() and secretStore: this.buildSecretStore(engine) — both always present — while assertEncryptionAvailable returns early on exactly that pair. So wherever the settings service has an engine, every declared-encrypted key CAN be encrypted and that refusal never fires; it is reachable only on an engine-less service or one whose host binds its own engine without a provider, and it is pinned there.

Verification

Reverse-verified in two directions, both predicted RED before running:

  1. Production sources reverted to origin/main, dogfood test kept — the parent's reproduction, red: the REST config_change filter polls to exhaustion (total 0), the shipped view's own declared filter returns 0 rows. sys_setting_audit had its row throughout, which is what makes the "unchanged" half a real measurement rather than an empty one.
  2. Only the wiring argument reverted to undefined — 9 of 11 seam cases red. The two refusal cases fail only on their non-vacuity half (expected [] to have a length of 1), which is precisely what that guard exists to catch: their zero-length assertions stay green either way.

Green after: service-settings 451/451 + typecheck; the new dogfood file 4/4; the three neighbouring sys_audit_log/settings dogfood files 11/11; all 10 downstream consumers of service-settings typecheck clean.

Gates run locally: check:nul-bytes (plus a control-byte self-scan of every changed file), check:cross-package-test-inputs, check:docs-audit-scope, check:route-envelope, check:test-source-alias, check:type-source-resolution, check-changeset-fixed, and — added from re-deriving against the actual diff — check:changeset-gate-self-tests, check:objectui-changeset, check:changeset-no-major, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract. All pass. check:dev-prereqs reports the worktree is not fully built (8 packages this diff never touches have no dist) — a local build-state condition, not a finding.

Scope note

packages/qa/dogfood/test/ is outside the dispatch's declared file surface. The card's acceptance is explicitly end to end, and only a booted stack with plugin-audit installed can see the real object, its real action enum and the shipped list view — service-settings must not depend on that plugin, which is the very reason the write is best-effort. The addition is one test file; production changes stay inside packages/services/service-settings/src/**, and packages/plugins/plugin-audit/** is untouched.


Generated by Claude Code

…change (#8145)
`SettingsServicePlugin` passed `undefined` for `bindEngine`'s
`SettingsAuditSink` argument — the slot documented since Phase 3 as the one
that writes the generic `sys_audit_log`. So every settings write was audited
into `sys_setting_audit` and nowhere else, and the declared `config_change`
enum member, the shipped `config_changes` list view and the console filter over
it were permanently empty (#7675 step 2: filter → total 0).
Dual-write, per the 2026-08-12 maintainer ruling on #7675 which left the choice
to the implementation: the generic ledger gets a `config_change` row per changed
key (attributed, tenant-stamped, digest only — never a value) and
`sys_setting_audit` keeps its rows unchanged. The new write is best-effort
because plugin-audit is optional, and a REFUSED write emits no row on either
ledger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
Measured on `origin/main`, where the case is red: vitest's 5s default cut the
poll short, so the failure read as a bare timeout instead of `waitForRows`'s
own message naming the object and the filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 3:13am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services/service-settings)
  • content/docs/plugins/packages.mdx(via @objectstack/service-settings)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/service-settings)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/service-settings)
  • content/docs/releases/v9.mdx(via @objectstack/service-settings)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-zhuang@claude