Skip to content

fix(plugin-audit): a user's own UI-state write writes no ledger row - #23866

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23804-own-ui-state-no-ledger-row
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23804-own-ui-state-no-ledger-row

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23804
Clause-②: no

What this changes

writeAudit (packages/plugins/plugin-audit/src/audit-writers.ts) now returns before it builds either row when the write is a user's own write to their own per-user UI-state row. The test is the existing isOwnUiStateWrite over the closed in-plugin list PER_USER_UI_STATE_OWNER_FIELD, today sys_user_preference mapped to user_id. Until now that test gated only the activity mirror (the #22961 change, PR #22996), so the sys_audit_log row was always written. This executes ruling record 6212163130 (letter B, the maintainer's 「同意」), on face 6209004397 and director delta 6211505939.

  • Scope, unchanged predicate. The acting user (userId: the ledger row's user_id, the activity row's actor_id) must be the owner on every side of the row that names one. No side naming an owner answers false. Another actor's write still writes the ledger row and the activity row: an administrator from Setup, a system write with no user, and a write that moves the row to another owner. Nothing about what the test admits was widened.
  • One test, judged once. The early return sits right after userId is resolved and before any read the writer pays for. The activity gate went back to enable.activities alone, because the own-write case never reaches it now.
  • Not changed: AUDIT_EXCLUDED_OBJECTS / excludeObjects / SKIP_OBJECTS (the skip is a condition inside writeAudit, not an object exclusion); packages/spec (object.zod.ts:259 is untouched); packages/platform-objects; objectui. Stored rows stay, and nothing is migrated.
  • Docblock. The 「ACTIVITY ONLY」 bullet above PER_USER_UI_STATE_OWNER_FIELD is now the ledger-and-activity rule and cites ruling record 6212163130. The file-level skip-rule bullet says the same.
  • Changeset .changeset/23804-own-ui-state-no-ledger-row.md, patch for @objectstack/plugin-audit. Its first paragraph says the ledger no longer records a user's own UI-state writes, and names the consumer this can surprise: an export of sys_audit_log to a SIEM or compliance archive.
  • The card's Target 2 is met by list membership. Target 3 needs no change (sys_audit_log_metrics already reads config_change).

Measured before writing the pins

On a real kernel (ObjectKernel + ObjectQLPlugin + AuditPlugin), a real SQLite driver and the shipped sys_user_preference, a one-off probe recorded what the engine hands the hook. The probe was not committed.

write by the owner session.userId result previous isOwnUiStateWrite
create owner full row, user_id = owner none true
update owner full row, user_id = owner full row, user_id = owner true
delete owner true (not evidence) full row, user_id = owner true
  • An owner's update setting user_id to null is refused by the engine (VALIDATION_FAILED, "User is required and cannot be cleared"), so no write can un-own a row past the test.
  • No other writer records these rows. The other sys_audit_log inserts are auth-event-audit.ts (session events), plugin-auth/src/admin-import-users.ts (import rows about sys_user), service-settings/src/config-change-audit.ts (config_change rows on sys_setting / sys_platform_setting) and read-audit.ts (read rows for opted-in objects). None writes a row for a sys_user_preference write, and none was changed.
  • Declaration closure unchanged (Clause-②: no). dist/index.d.ts and dist/index.d.mts built from this head and from the base's audit-writers.ts are byte-identical (cmp), and the restore was proven by blob hash equal to HEAD.

Pins

In two test files, audit-writers.test.ts (fake engine) and audit-writers.own-ui-state.integration.test.ts (real engine):

  • plugin-audit: a user's own sys_user_preference writes (ui.recent) come back to them as "Updated User Preference 'ui.recent'" inbox notifications — the console bell shows six of them and nothing else #22961's fake-engine block: the three own-write cases (create, update, delete) now assert no sys_audit_log row and no sys_activity row. The three CONTROL writers (an admin, a system write, a move to another owner) are unchanged and still assert one ledger row and one activity row.
  • New real-engine block (the kernel above, both tables read at rest by SQL). Each case reads only the rows its own write adds:
    • the owner's own create, update and delete → no ledger row and no activity row; the create and update cases end with an administrator's write to the same row, which must add exactly one ledger row (a live-writer control);
    • an administrator's update of another user's row → one ledger row naming the administrator, plus its activity row;
    • a system write with no user → one ledger row, user_id null;
    • the owner moving the row to another user → one ledger row;
    • the same user's update of a sys_sharing_rule → one ledger row (the card's Acceptance 1).

Ablations (committed head 5ff57252e2; the suite reads src/ through relative imports, so no dist/ leg)

Both used node scripts/ablation-replace.mjs in wrap mode. The tool verified the anchor count, the blob change and the restore (blob equal to HEAD, git diff HEAD empty) on disk.

  1. Delete the ledger skip (if (isOwnUiStateWrite(ctx.object, userId, before, after)) return;, anchor 1 → 0): 6 failed / 101 passed. The failures are exactly the six own-write pins, three fake-engine and three real-engine, for example expected [ { id: …, …(15) } ] to have a length of +0 but got 1.

    • A first run of this ablation, before the real-engine cases measured only their own write, failed 9. The extra three (admin, system, move) failed only because their seed is the owner's own create, so with the skip gone the seed added a row. They were decoupled in 5ff57252e2, and the run above is on that head.
  2. Remove the owner equality (return owners.length > 0 && owners.every(…) → return owners.length > 0;): 6 failed / 101 passed:

    • the fake-engine admin and move CONTROLs;
    • the real-engine admin and move cases;
    • the live-writer control leg of the real-engine own-create and own-update cases, for example expected [] to deeply equal [ [ 'update', 'u_admin' ] ] at audit-writers.test.ts:899 and :914.

    The system-write pins and the sys_sharing_rule pin stayed green, as predicted: the acting-user check survives that mutation.

Reverse reading at no extra cost: the base's audit-writers.test.ts, run against this head's source, fails exactly its three own-write pins (expected [] to have a length of 1 but got +0).

Tests and gates (head 03ba0dc800, after main at a31ff2eb5d was merged in)

Every heavy run went through scripts/pm/os-verify-lock.sh. The verdict lines are quoted.

  • pnpm --filter @objectstack/plugin-audit test: 51 files, 840 tests passed (VERDICT command-exit 0).
  • pnpm --filter @objectstack/plugin-audit typecheck: VERDICT command-exit 0. The test layer compiles with 0 errors. tsc -p tsconfig.test.json --listFiles lists src/audit-writers.test.ts (count 1), so the typecheck reaches the new block.
  • vitest run src/audit-writers.test.ts: 107 passed (at 5ff57252e2, the head the ablations used).
  • Dependency closure turbo run build --filter='@objectstack/plugin-audit^...': VERDICT command-exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gave 68 commands for these 3 paths. All 68 ran with the exit code captured before any pipe.
    • --ran reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
    • Two gates first exited 3 (PREREQUISITE NOT MET) in the fresh worktree: pnpm check:i18n (no built CLI) and pnpm check:dual-build-cjs-loads (no workspace dist/). After their prerequisite builds (turbo replays, 61 of 61 and 74 of 75 cached), each was re-run and exited 0. The --ran list carries the re-run codes.
  • pnpm test:repo was not run locally (CI's Test Core runs it): this diff changes no file in that project.
  • CI check-runs: pending at the time of writing; the os-dev report on the card cites them.

Acceptance notes


Generated by Claude Code

writeAudit now returns before building either row when the write is a
user's own write to their own per-user UI-state row (isOwnUiStateWrite on
the closed PER_USER_UI_STATE_OWNER_FIELD list, today sys_user_preference).
Another actor's write (an administrator, a system write, a write that
moves the row to another owner) still writes the ledger row and the
activity row. Ruling record 6212163130, letter B.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
The first ablation of the ledger skip also reddened the administrator,
system and move cases, only because their seed is the owner's own create.
Each case now measures the rows its write under test adds.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
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 16, 2026
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 f7e836da1ed344aaa6936b9de8b173d0f326b9ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 66496d75deb7bce6f254cf9320eb9a96461be1df — the merge of head c98019b0eceda8918fd52df7a37725132ac9cb39 into base f7e836da1ed344aaa6936b9de8b173d0f326b9ec, 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 66496d75deb7bce6f254cf9320eb9a96461be1df && git checkout 66496d75deb7bce6f254cf9320eb9a96461be1df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7e836da1ed344aaa6936b9de8b173d0f326b9ec c98019b0eceda8918fd52df7a37725132ac9cb39 && git checkout -B drift-repro f7e836da1ed344aaa6936b9de8b173d0f326b9ec && git merge --no-ff c98019b0eceda8918fd52df7a37725132ac9cb39

node scripts/docs-audit/affected-docs.mjs --json f7e836da1ed344aaa6936b9de8b173d0f326b9ec

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

The kernel, ObjectQL, SQLite and AuditPlugin imports took the isolated
load of audit-writers.test.ts from about 12.5 s to about 17 s. The block
moves verbatim (docblock opening reworded) into
audit-writers.own-ui-state.integration.test.ts; audit-writers.test.ts
keeps the fake-engine pins and returns to the base file's imports.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-16T10:44Z

Reviewed against GitHub at head c98019b0ec, not against the dev's reports (6213185115, and the split addendum 6213730945 on #23804).

  • Form: draft, base main. The first line is Fixes #23804, then Clause-②: no; no other card's number sits beside a closing keyword.
    • Four files, all on claim 6212340486's surface: audit-writers.ts, audit-writers.test.ts, the split-out audit-writers.own-ui-state.integration.test.ts (this seat's patch request), and one patch changeset for @objectstack/plugin-audit.
    • check-governed-merges.mjs --pr 23866: NOT governed, 293 changed lines.
  • The ruling, item by item (6212163130):
    1. Scope: met. One early return at the top of writeAudit, on isOwnUiStateWrite (unchanged), before either row is built. A write the test does not admit writes both rows as before.
    2. No spec key, no object flag: met. AUDIT_EXCLUDED_OBJECTS / excludeObjects / SKIP_OBJECTS are untouched. The activity gate is back to enable.activities alone.
    3. Stored rows stay: met. Nothing is deleted or migrated.
    4. The docblock: met. "ACTIVITY ONLY" became the ledger-and-activity rule citing 6212163130, with "⛔ never a business or security object" on membership.
    5. Pins: met, on a real kernel. A user's own create, update and delete → no ledger row and no activity row. An administrator's update of another user's row → one ledger and one activity row. A system write → a ledger row. A move to another owner → a ledger row. A sys_sharing_rule update → a ledger row.
    6. The changeset's first paragraph: met. It says the ledger no longer records a user's own UI-state writes, and names the SIEM / compliance-archive export.
    7. Target 2 and 3: met by list membership; there is no reader change.
  • Measured premises:
    • The seam was as read: before this PR, the own-write test gated only the activity row.
    • Create, update and delete each answer "own" on a real kernel, because a delete's previous names the owner.
    • An owner clearing user_id is refused by the engine.
    • No other sys_audit_log writer records these rows.
    • The declaration closure is byte-identical, so Clause-②: no holds.
  • Ablations, on the final layout:
    • Removing the early return turns exactly the 6 own-write pins red, across both files.
    • Removing the owner equality turns the administrator and move cases and the live-writer controls red, while the system-write and sharing-rule pins stay green.
    • Both restores were proven by blob. The dev reported the first run's coupled seeds (9 red), which it fixed and re-ran.
  • CI: 40 check-runs, latest per name: 34 success, 0 failure. 6 skipped, all on the roster: Build Docs, Console Pin Gate, Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance.
  • Gates: 68 derived, 68 exit 0, reconciled with --ran.
  • Test budget: with the real-engine pins moved out, audit-writers.test.ts reads 11.62 s isolated (base 12.53 s). The new integration file reads 16.78 s isolated, almost all first-load import, like its twin activity-withheld-update.integration.test.ts (15.59 s). The lane's budget card is raised by the full-battery refresh, not by an isolated read.
  • The open question, delegated writes: A, accepted. A write an MCP OAuth client makes for the owner is judged on the delegator (ADR-0090 D10), so it is skipped too, and its performed_by is not recorded. The ruling binds the scope to isOwnUiStateWrite. Only per-user UI state is affected. This seat reports it to the maintainer as a note; if B is wanted, it is one extra condition.
  • PR body: one line, under ## Pins, now names both test files (this seat's edit).

ACCEPT. This act marks the PR ready and enables auto-merge through the fleet relay. It lands through the merge queue.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

Audit log: sys_user_preference writes are not audit events

2 participants