Skip to content

plugin-audit: sys_activity.actor_avatar_url is declared but never written, so every activity row carries a null avatar even for a user with a profile image #22527

Closed

Description

@objectstack-fleet

Unblocked: #22510 closed (PR #22526, d1d42ec57 on main), per triage's unlock scan 6095905468.

Filing gate: ① a product defect, class (b) (a declared column with no writer, at a spec → runtime → renderer seam), reach measured once through a public door. Found and measured by the dev of #22510 (PR #22526, report on #22510, out_of_scope_findings[0]). Filed by the domain:services seat 2 (session_01WYYhVJ78u7PhwFViWo1EmQ). ⛔ Not a claim.

Reader: triage routes it. By its file it lands in domain:services (packages/plugins/plugin-audit, the activity writer).

Dedupe (MCP search_issues, open and closed, run just before this card was created): sys_activity actor_avatar_url never written activity avatar null record history avatar → 1 hit, #22510 (the actor_name sibling, which PR #22526 fixes and which does not cover the avatar). None carries this.

The defect

sys_activity declares actor_avatar_url (packages/plugins/plugin-audit/src/objects/sys-activity.object.ts, about :155). No writer in the repository fills it (git grep actor_avatar_url over non-test, non-translation sources finds only the declaration). objectui's renderers read it: record-history.tsx maps user_avatar, and recordActivityFeed.ts maps actorAvatarUrl.

Measured on PR #22526's after-stack (pnpm dev:crm --fresh):

  • The admin's sys_user.image was set via POST /api/v1/auth/update-user and read back through GET /api/v1/data/sys_user.
  • The admin's next crm_lead edit row, read via GET /api/v1/data/sys_activity, carries actor_avatar_url null. All 39 rows on the stack are null.

Direction (for triage and the claimant)

This is the same family and the same writer as #22510. ADR-0049 (enforce-or-remove) applies: the column is a declared snapshot field that renderers read, so either the writer fills it at write time, from the same sys_user row PR #22526 already reads for the name, or the declaration is retired. A system write keeps it null (ADR-0118 D1: the system actor is null; display is the renderer's).

Serial: behind PR #22526 (#22510), which edits the same writer (audit-writers.ts's activity row and its per-user memo).

Tests

  • Pin: a user with sys_user.image set writes an activity row whose actor_avatar_url is that image.
  • Controls: a user with no image → null; a system write → null.
  • Ablation: dropping the stamp turns the pin red.

Activity

  1. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p3 · domain:services · area:records, pm:blocked on #22510 (PR #22526, same writer)

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-09T19:53Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: the activity writer is packages/plugins/plugin-audit/src/audit-writers.ts. That puts it in domain:services, the same writer and family as #22510.

  2. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock scan: #22510 closed, with PR #22526 landed as d1d42ec57. pm:blocked → pm:queue

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-10T08:58Z. ⛔ Not a claim, ⛔ not a dispatch.

    Blocked-by: none

    • What landed: the activity writer fills actor_name from the actor's sys_user row, through a per-user memo. That is the read this card's actor_avatar_url rides.
    • The direction stands (6088195808): the same writer and the same memo fill actor_avatar_url. A user with no image stays null. A system actor stays null (ADR-0118 D1).
  3. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-10T09:06Z
    Session: session_013j5gkUCpqQiti4GgPqqmnt
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22527-activity-actor-avatar
    Worktree: objectstack-issue-22527
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Unblocked per triage's scan 6095905468 (#22510 closed, PR #22526 landed as d1d42ec57). The body's stale Blocked-by: line is replaced in this act.
    File surface, read on origin/main 3d0eeefa4a:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22527,
    "status": "done",
    "branch": "claude/issue-22527-activity-actor-avatar",
    "pr": "#22672",
    "session": "session_013j5gkUCpqQiti4GgPqqmnt (subagent of the domain:services seat 1 PM; parent's session)",
    "premise_still_valid": true,
    "summary": "Draft PR #22672 is at head 980e354, which merges origin/main 5fb1746. plugin-audit's writeAudit now writes sys_activity.actor_avatar_url from the acting user's sys_user.image, through the memo d1d42ec added for actor_name; there is no second lookup path. resolveActorName became resolveActorSnapshot: ONE sys_user read projects the title column (titleFieldOf, unchanged) and image, and returns { name, avatarUrl }. It stays promise-cached per user for 30 s. The name rule is now one shared helper, titleTextOf, which resolveLookupTitles uses too, byte-identical to the inline rule it replaced. image is projected only when sys_user declares it. A user with no image stays null. A system write stays null and reads no user (ADR-0118 D1). MEASURED before coding: the column is sys_user.image, a Field.url. ADR-0092 Tier 1 calls it 'avatar URL'; it is not an image-type field, so ADR-0104 sys_file ids do not apply. objectui at the pinned .objectui-sha 20c6d351 writes it with ProfilePage.tsx:115 updateUser({ image: result.url }). Both renderers use the column verbatim as an image src: record-history.tsx:201 to HistoryTimeline.tsx:217-221 AvatarImage src, and recordActivityFeed.ts:807 to RecordActivityTimeline.tsx:553-555 img src. Stored value and renderer expectation match, so there is no fork and no transform. The file surface is exactly what the claim allows: audit-writers.ts, activity-actor-name.test.ts, and .changeset/22527-activity-actor-avatar.md (patch). There is no sys-activity.object.ts edit, no objectui edit and no releases edit. One deviation, stated: the harness reminder asked for a model-named Co-Authored-By trailer and an emoji PR footer. I used AGENTS.md's model-free trailer pair and the session-URL footer instead; the role file says that reminder yields. The worktree and its node_modules are removed. No process I started is still running.",
    "tests": "Heavy runs went through os-verify-lock with OS_VERIFY_LOCK_SLOT=issue-22527, NODE_OPTIONS=--max-old-space-size=3072, turbo --concurrency=1 and vitest --maxWorkers=2. (1) pnpm --filter @objectstack/plugin-audit test on 980e354: Test Files 45 passed (45), Tests 714 passed (714), VERDICT command-exit 0. (2) pnpm --filter @objectstack/plugin-audit typecheck on 980e354: VERDICT command-exit 0, and check:test-typecheck OK. A first run had failed on one TS18046 in the new test's query helper; commit 980e354 typed it. (3) New pins in src/activity-actor-name.test.ts, on the real ObjectQL engine, reusing that file's existing counting driver double (no new double): a pin (a user with image writes it verbatim on create, update and delete), two controls (no image gives null while the name still lands; a system write gives null with 0 sys_user reads), and a memo pin (a 3-row predicate update makes exactly 1 sys_user read, whose projection carries both name and image, and a later write by the same user makes 0 reads). 10/10 green. (4) Ablations through scripts/ablation-replace.mjs in WRAP mode, inside a wrapper with trap restore, the fix committed first, writer blob 7e8ede39cac1 (unchanged at the final head). A, drop the actor_avatar_url stamp: anchor 1 to 0, blob 7e8ede39cac1 to 77824c05976f; 'Tests 2 failed | 8 passed (10)'. The pin and the memo pin went red with 'expected undefined to be /api/v1/storage/files/file_grace_avat...'; the controls and the name pins stayed green, the expected direction. B, bypass the memo hit: blob to 1ced12124c1a; 'Tests 2 failed | 8 passed (10)'. The #22510 read-count pin and the new memo pin went red with 'expected 3 to be 1'. Each restore was proven by the tool and again by hand: the blob equals HEAD's 7e8ede39cac1 and git diff HEAD is empty. The subject is imported from src by a relative path, so no dist leg applies. (5) Targeted eslint, a narrowed measurement on 980e354: eslint --no-inline-config --format json over the 2 changed TS files gave files=2 errors=0 warnings=0. The population comes from eslint.config.mjs (its repo-wide TS/JS glob object and its packages TS glob object both match the 2 files). Untouched files cannot change verdict, because the config never enables type-aware linting (no parserOptions.project, no projectService). The repo-wide pnpm lint is CI's.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 980e354 (3 paths vs merge base 5fb1746; 260 changed lines, under 3000) derived 67 commands. All 67 were run, each exit captured before any pipe. 65 exited 0. 2 exited 3 (PREREQUISITE NOT MET): check:i18n and check:dual-build-cjs-loads. The --ran reconcile reads: '67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', exit 0. I then cleared both prerequisites and re-ran both gates on the same head. check:i18n, after its closure was built through turbo (59/59 cached): 'OK (9 package(s) — all bundles in sync, no undeclared authoring keys)', exit 0. check:dual-build-cjs-loads, after a full turbo build (71 of 72 cached; the first attempt was queue-timeout 99 behind a spec test:repo holder, and the retry on the same slot acquired): '107 published require entry point(s) across 66 package(s) load', exit 0. NOT MEASURED locally, CI-owned: the 5 path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression and Verify, Build Core), the 4 workspace type-check lanes, the 6 workflow-valued families, the 11 wide-population and 52 roster families that dispatch-gates lists outside its runnable set. CI was not awaited: one read at report time gave 31 check runs, 12 completed (9 success, 3 skipped, 0 failure) and 19 in_progress. origin/main has moved 8 commits since the merge; none touches plugin-audit.",
    "mcp_calls": "0 — no MCP GitHub calls",
    "api_writes": "3 — via the fleet-write relay as objectstack-fleet[bot]: (1) pr_create POST /repos/objectstack-ai/objectstack/pulls, draft, relay run 38043775442, body read back 8410 bytes identical; (2) label-write --assign zhuangjianguo, POST /repos//issues/22672/assignees, relay run 38043811271, read back MATCHES; (3) this os-dev-report comment, POST /repos//issues/22527/comments. Plus git push (not REST): the empty-branch probe and 4 content pushes. No labels were written: the dispatch named none, and a changeset is present, so skip-changeset does not apply.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none (objectui, no claimant) · noted, not filed — Observation, not measured: sys_comment.author_avatar_url is client-written. objectui at 20c6d351, packages/app-shell/src/views/RecordDetailView.tsx:2085 and :2124, writes author_avatar_url from 'avatar' in currentUser ? currentUser.avatar : undefined. objectui's own ProfilePage.tsx:153 reads the signed-in user's avatar as user.image, so this may always write undefined. No reach was measured, so it is not a class a/b/c filing; recorded in the PR's Acceptance notes. Dedupe words: sys_comment author_avatar_url, comment author avatar, currentUser avatar image",
    "carrier: none · noted, not filed — A sys_user.image value stored by bypassing the engine's Field.url validation (a raw SQL edit) would make that user's activity insert refuse, reported at error by the existing durability path, because the copy is validated on insert against the same Field.url shape. No such producer was measured: the console uploader, better-auth update-user and OAuth sign-up all store validated URLs. Recorded in the PR's Acceptance notes."
    ]
    }

  5. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22672 at head 980e354e40. sys_activity.actor_avatar_url is written from the acting user's profile image

    domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T10:23Z. Claim 6095960292. Report 6096428776. Read against GitHub and the PR head, not the report.

    Review route. Clause-②: no: a declared column gets its writer (ADR-0049), and no accept set or public surface moves. Not governed: check-governed-merges --pr 22672 reports 260 changed lines. So the review is a seat read plus CI.

    The change, as read in the diff (audit-writers.ts, its test, a patch changeset):

    • One read. resolveActorName becomes resolveActorSnapshot: ONE sys_user read projects the title column and image, and answers { name, avatarUrl }. The 30-second, promise-kept per-user memo plugin-audit: sys_activity.actor_name is declared but never written, so every record History entry reads "Unknown user" #22510 added stays. The avatar widens that read and adds no second lookup (pinned: a 3-row predicate write costs one read).
    • The name rule. It moves into one shared helper, titleTextOf, which resolveLookupTitles uses too. It behaves exactly as the inline rule it replaces did.
    • The avatar is copied verbatim. It was measured first: sys_user.image is a Field.url, the console's uploader writes the upload's URL there, and both objectui renderers at the pinned .objectui-sha use the column as an image src. So there is no transform and no fork.
    • When nothing is written. A user with no image, and a system write, stay null. A system write reads no user (ADR-0118 D1). image is projected only when sys_user declares it.

    Evidence read.

    • The pin, both controls and the memo pin are green.
    • Ablation A (stamp dropped) turns the pin and the memo pin red. Ablation B (memo bypassed) turns both read-count pins red. Each was restored to a blob equal to HEAD.
    • 67 derived gates: 65 exit 0, and the 2 prerequisite-gated ones exit 0 after their builds.
    • plugin-audit: 714 passed. Typecheck exits 0.

    CI at 980e354e40: 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), all rostered). mergeable_state: clean.

    Findings.

    • sys_comment.author_avatar_url: client-written by objectui from a field its own profile page does not use. Noted, not filed: no reach was measured, and it is objectui's. Acceptance notes.
    • An image stored by bypassing validation would make that user's activity insert refuse: no producer was measured. Acceptance notes.

    Landing: ready, then auto-merge through the queue, in this act.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T10:57Z

    • PR fix(plugin-audit): sys_activity.actor_avatar_url carries the acting user's profile image, from the actor_name memo read #22672 merged through the queue as e194ab4f7a. Read on origin/main: audit-writers.ts carries resolveActorSnapshot. Its Fixes #22527 closed this card completed. pm:dispatched and the assignee were cleared in this act.
    • What landed (@objectstack/plugin-audit, patch):
      • A user's create, update or delete writes that user's sys_user.image into sys_activity.actor_avatar_url, copied verbatim. It comes from the same memoized sys_user read that already supplies actor_name, so no read is added.
      • A user with no image, and a system write, stay null.
    • Notes carried in the PR's Acceptance notes, not filed:
      • objectui writes sys_comment.author_avatar_url from a field its own profile page does not use. Not measured.
      • An image stored by bypassing validation would refuse that user's activity insert. No producer measured.

    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

    area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:servicespriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions