Skip to content

content(blog): can you prove who opened a customer record, including an AI agent? record-view auditing (slot 2026-10-26) - #284

Open
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/pm-dispatch-objectos-ju9td1-issue-274
Open

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/pm-dispatch-objectos-ju9td1-issue-274

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #274

Slot: Mon 2026-10-26, on the shared calendar on #267. Merge on the slot day only. Seat review. The seat reviewed the post and its diagrams at native size. Cover-column and stop-line tightness was fixed in 27d6efb, after which a clearance scan reports 0 findings and the seat re-viewed the cover. The edges the card claimed all hold on 17.7.0, and the MCP get_record path is measured on HTTP. ACCEPT.

Slot Mon 2026-10-26 (date: 2026-10-26, moved from the card's 10-28 by the seat's calendar change during the run). Merge on the slot day only; if the slot moves again, re-date the post and move the three back-linked updated values with it.

What this adds

  • content/blog/prove-who-opened-a-customer-record-view-auditing/ — the post in all eight locales (index.mdx, zh-Hans, ja, ko, de, es, fr authored; zh-Hant produced by pnpm gen:zh-hant), cover pair (cover-en.svg, cover.svg) and one diagram pair (which-reads-land-en.svg, which-reads-land.svg). The diagram adds what the cover lacks: the four-condition rule and where reads C, D and E stop.
  • Back-links with updated: 2026-10-26, one closing sentence each: eu-ai-act-runtime-audit (all eight locales; its existing updated moved from 2026-10-15), when-ai-agent-deletes-production-database (all eight), objectos-agent-permission-boundaries (en, zh-Hans; zh-Hant regenerated). Links to the glossary term audit-trail from every locale (/en/ or /zh-Hans/ form where the locale has no glossary; the cross-locale rule in content-lint accepts that).
  • Title is query-shaped; the one number is four (the four things a view row never records), backed by the measurement five reads → two rows. Ownable concept: boundaries declared rather than discovered. Audience it, topic governance. Canonical T4 closes every locale verbatim from src/lib/positioning.ts (standalone form).
  • Opening and proof type differ from this week's posts: it opens with the two queried ledger rows as a table (not "Answer first" with a REST transcript, not a scenario with a diagram), and the proof is queried audit rows plus a SQLite file probe.

Proof artifact — produced on the published @objectstack/cli 17.7.0

examples/app-crm exported from tag @objectstack/cli@17.7.0 (commit 4e4e881427), every @objectstack/* dependency pinned to the published 17.7.0 packages (cli, runtime, spec, service-i18n, driver-sql, formula, objectql, plus @objectstack/plugin-audit 17.7.0 added as a dependency, since pnpm's strict layout does not expose the CLI's transitive copy to the app), plugins: [new AuditPlugin({ readAudit: { objects: ['crm_contact'] } })] added to objectstack.config.ts, booted with objectstack dev --compile --no-watch --port 49200. Logs and raw responses: /tmp/claude-0/-home-user/2224823d-3484-535e-94a5-c071ba6af395/scratchpad/issue-274/proof/ (server1.log, transcript.log, readA.json, readB.json, ledger.json, where.log, phase.sh, where.mjs).

Boot: Runtime: 1 plugins; Plugins: 37 loaded including Audit, com.objectstack.audit; the warning Plugin superseded: 'com.objectstack.audit' — the later registration (v1.0.0) REPLACED the earlier one (v1.0.0)… (server1.log:98); objectstack.telemetry.db created beside objectstack.db.

Actors (admin API): dana@example.com and agent-svc@example.com each given a sys_user_position row with position: 'sales_rep'; nobody@example.com with none. API key osk_xtVIackn… minted by agent-svc via POST /api/v1/keys.

# Who Read Answer Row in sys_audit_log?
A Dana (cookie) GET /api/v1/data/crm_contact/h7UV6RJfS5ykgOH- 200, Ada's record yes — user_id PdP4MCXuWr4khHoyN1R4uw7U8G4LtsCv, created_at 02:33:05.512Z
B agent (x-api-key) MCP tools/call get_record { objectName: "crm_contact", recordId: "HwvcWKZh3n1TM55l" } on /api/v1/mcp 200, Linus's record yes — user_id S8My5629W2MZy813Pcx0Hgpyq1DiNk5i, created_at 02:33:05.618Z
C Dana GET /api/v1/data/crm_contact 200, 3 records no
D Dana GET /api/v1/data/crm_account/eSLGuoysxgUk5mvO 200 no (object not opted in)
E nobody GET /api/v1/data/crm_contact/h7UV6RJfS5ykgOH- 403 PERMISSION_DENIED no; server log: Access denied: operation 'findOne' on object 'crm_contact' is not permitted for positions [org_member, everyone]

Ledger before: {"records":[],"total":0}. Five seconds after the reads, as admin, GET /api/v1/data/sys_audit_log?filter={"action":"read"} → "total": 2; both rows: action: read, old_value: null, new_value: null, ip_address: null, user_agent: null, actor = user_id, organization_id/tenant_id = org_mvbs6g8ek7hnfdcf (the viewer's session org; the seeded contact's own organization_id is null), updated_at 2.0 s after created_at (the batch drain). The same query as Dana → 403 PERMISSION_DENIED on sys_audit_log. better-sqlite3 from the app's own store: objectstack.db → no such table: sys_audit_log; objectstack.telemetry.db → create 40, login 5, read 2, update 4.

Card inference confirmed by measurement: the card said MCP get_record should qualify (inferred from the predicate rule). It does on the HTTP transport (row B), because /api/v1/mcp → buildMcpBridge.get → callData('get') → protocol.getData → engine.findOne(object, { where: { id } }). The card's edges all held: list never logged, no field values, no IP, no refused reads.

Sources (tag @objectstack/cli@17.7.0 unless noted; line numbers at the tag)

  • content/docs/permissions/record-view-auditing.mdx — :7-9 "answers the question that comes after it … who actually opened it, and when?"; :12-13 "off until a deployment names the objects it wants recorded … its scope has real edges"; :45-51 the three readAudit keys, defaults 50 / 2000, "Empty registers no hook at all"; :63-68 "There is no object-metadata key and no environment variable. enable.auditReads does not exist … a declaration a reviewer mistakes for coverage is worse than an absent feature"; :74-114 under os serve, auto-registration "before it walks plugins", the Plugin superseded block and "is the opt-in working, not a misconfiguration"; :118-127 "There is no stack-config key either." and "requires: ['audit'] is not a substitute — it … constructs the plugin with no options and offers nowhere to name objects"; :132-147 the two conditions ("It materialized exactly one record", "Its predicate pinned the primary key", findOne(object, { where: { id } }), $or/$not disqualify, depth 8); :150-153 "List and search reads are never recorded — including a list read that happened to return exactly one record … 'record-detail views', not 'reads'"; :156-172 the column table ("The instant the record was viewed, not the instant its batch drained"; "falling back to user_id"; "The viewed record's own organization, falling back to the viewer's session organization"; "Always null") and the org-B wall reason; :174-188 the four non-records ("No field values, ever." … "ahead of its field masking"; "Not what the viewer actually saw."; "No IP address and no user agent."; "Nothing about refused or failed reads."); :190-199 the two boundaries (session.isSystem, "sudo() keeps the caller's user id", "a row naming nobody only adds noise"); :201-222 Record Views under Diagnostics, append-only, view_all_audit_log, "retained hot for 90 days, then archived for seven years"; :224-255 failure posture ("enqueues and returns", 10,000 ceiling, "once per cause", "Both failures are invisible from everywhere else … confident, wrong, short answer", telemetry "sibling SQLite file"); :268 canonical source. Diff tag → origin/main (570c7a2d95): only the getLocale paragraph added and the "only key" sentence reworded (main :45-46, :61-64); every quoted sentence is identical on both.
  • content/docs/kernel/runtime-services/audit-service.mdx:11-22 — the three ledger surfaces; "Record views do not travel through it either … afterFind hook, under a per-object opt-in given at construction".
  • content/docs/api/data-api.mdx:21 — the filter query parameter (JSON).
  • content/docs/ai/connect-mcp.mdx:97-134 — API keys, POST /api/v1/keys, the x-api-key header; :153-167 — "for a full-authority local agent, mint the key on a platform-admin or dedicated service identity; for a scoped one, mint it on a user with exactly the access it should have"; :288-305 the shell initialize / tools/list / tools/call form used.
  • content/docs/permissions/authentication.mdx:931-946 — POST /api/v1/auth/admin/create-user body; content/docs/permissions/administrator-guide.mdx:100-108 — sys_user_position = user + position; packages/plugins/plugin-security/src/objects/sys-user-position.object.ts:78-84 — user_id, position.
  • packages/plugins/plugin-audit/src/read-audit.ts — :347-359 extractDetailReadId (array/null never a view); :364-389 findIdPin ($or/$not → null, $and walked, depth 8); :683 if (session.isSystem === true) return;; :690 if (!userId && !actor) return;; :647-648 old_value: null, new_value: null; :657-658 actor = event.actor ?? event.userId; :727 eng.registerHook('afterFind', auditRead, { object: [...auditedObjects], packageId }); :577-583 the "no such table … DIFFERENT datasource … os dev provisions one by default as a SIBLING SQLite file" message.
  • packages/plugins/plugin-audit/src/audit-plugin.ts:234-250 — readAudit.objects wiring; :247 ctx.logger.info('AuditPlugin: record-view auditing installed on N object(s) …') (why the default warn boot shows only the superseded line; packages/cli/src/commands/dev.ts:228 "default warn").
  • packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts:94-109 — the record_views view (columns, action in ['read'], empty state "Record-view auditing is opt-in per object"); :238 actor, :317 old_value, :325 new_value, :334 ip_address, :342 user_agent, :350 tenant_id.
  • packages/cli/src/commands/serve.ts:4314-4323 — "Registered with NO options … put its own new AuditPlugin({ readAudit: … }) in the stack's plugins array, which this file registers further down — AFTER this line"; :4390-4392 await kernel.use(new AuditPlugin()); :4433-4440 the superseding kernel.use(pluginToLoad) in the plugins loop; packages/core/src/plugin-registration.ts:97 the Plugin superseded: text.
  • packages/mcp/src/mcp-http-tools.ts:883-907 — the get_record tool (bridge.get(objectName, recordId)); packages/runtime/src/domains/mcp.ts:609-611 — HTTP bridge get → callData('get', { object, id }); packages/runtime/src/action-execution.ts:240-242 — protocol.getData(...); packages/metadata-protocol/src/protocol.ts:13243-13297 — getData builds where: { id: request.id } and calls this.engine.findOne (:13283).
  • packages/rest/src/rest-server.ts:8964 — GET /data/:object/:id → p.getData(getRequest).
  • examples/app-crm/src/security/sales-positions.ts:44 — crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }; examples/app-crm/src/data/index.ts:25-27 — the three seeded contacts.
  • objectos content/docs/operate/audit-logs.mdx:25-28 at origin/main 76988dad4 — "The object is read-only and immutable" (linked as https://docs.objectos.ai/docs/operate/audit-logs, the form published posts use).
  • The Plugin superseded, Access denied, ledger rows and SQLite outputs are the 17.7.0 process's own output (proof/server1.log, proof/transcript.log, proof/where.log).

Gates

Gate Result
pnpm content:lint pass (355 files, 46 glossary terms); warnings only: title 133 cols / description 229 chars (advisory), date/updated in the future (slot post) — clint2.log
pnpm content:lint --published pass — clint-pub.log
pnpm svg:lint (the four new SVGs) pass — 2 × 109 text boxes, 0 findings (svglint-en3.log, svglint-zh1.log); within pnpm build: 122 SVGs, 2691 boxes, OK
pnpm check (under os-verify-lock.sh) VERDICT command-exit 0; 0 errors, 0 warnings, 0 hints (160 files) — check.log
pnpm build (under the lock; second run, on the committed content) VERDICT command-exit 0; 920 pages in 48.97 s; --dist prose (494 pages), CJK breaks, quotes (13149 marks), links (99460) all pass; tree clean afterwards (git status empty) — build2.log
pnpm seo:smoke pass (910 HTML pages; 594 sitemap URLs; 5679 canonical/hreflang; 17438 prose blocks language-checked; 99450 links) — seo.log
pnpm gen:zh-hant 48 generated, 0 hand-maintained, 10 body links pointed at /zh-Hant/; one locative warning (账本里) resolved by rewording the zh-Hans source to 账本中 — gen3.log

First pnpm build failed on content-lint --dist: ja, zh-Hans and zh-Hant rendered 10 literal ** each, a bold span ending 。** directly followed by a letter is not right-flanking in CommonMark. Moved the full stop outside eight such spans in zh-Hans and ja, regenerated zh-Hant, rebuilt green (build.log → build2.log).

Browser check (looked at, not only asserted)

astro preview on 127.0.0.1:49205; Playwright with the preinstalled Chromium. en and zh-Hans article plus /en/blog/ at 1366×625, 1440×750, 1512×830, 1920×945, 390×844 and 360×800, light and dark, lazy images scrolled into view first: 210/210 assertions pass (HTTP 200, no horizontal overflow, no element past the viewport outside a scroll container, no repeated image, cover rendered, every body image loaded) — shots.log, results.json. Viewport crops at the hero, the opening ledger table, the diagram, the column table and the closing section were taken for six locale/viewport/scheme combinations and inspected (hero 1440 light, 1920 dark, zh-Hans 1366 light; ledger table 390 dark and 1440 light; diagram 390 dark and 1440 light; zh-Hans column table 360 dark; closing 1440 light). The blog list was scrolled to the new card at 1366 light, 390 dark, 1920 dark and zh-Hans 1440 light: first position, cover, Oct 26, 2026 / 2026年10月26日, tags; at 390 dark the lazy cover had not loaded at first measurement and was re-measured after load (natural width 267). The six back-linked pages checked (en × 3, zh-Hans, ja, zh-Hant) all render Updated Oct 26, 2026 (datetime="2026-10-26") and the new link resolves with 200. Every new SVG was rendered at native size (1200×675, 1200×720) on light and dark; one defect found and fixed before commit (the English cover's old / new and ip columns collided — "null · nulbull"; columns re-spaced, gate re-run, re-rendered and looked at). Screenshots: /tmp/claude-0/-home-user/2224823d-3484-535e-94a5-c071ba6af395/scratchpad/www/shots-274/ (svg/, crops/, listcard-*, backlink-*, results.json).

Seat fixup (second commit 27d6efb, on top of 2121cd0)

Three visual findings from the seat's review, fixed in both language variants:

  • Covers (cover-en.svg, cover.svg): the ledger row bands widened to x=14..642 and the five columns re-spaced to x=28 / 150 / 212 / 392 / 512 with the ids shortened to four characters (PdP4…, S8My…, h7UV…, Hwvc…). By the gate's own widths every column gap is now ≥ 20px (created_at→user_id 21.2, user_id→object · record 20.0, object · record→old / new 20.4, old / new→ip · user agent 27.6) and the last value ends 37.6px inside the band; the left edge sits 14px inside. The two footnote lines moved up 4–6px (bottom clearance 10.1 → 14.1).
  • Diagrams (which-reads-land-en.svg, which-reads-land.svg): the three WHERE C, D AND E STOP lines shortened ("C fails 3 — a list, never recorded", "D fails 1 — crm_account not named", "E fails 2 — the hook never reached"; zh likewise), now ≥ 50px from the card edge; the two ledger row cards grown 110 → 116px so the last line keeps 14px below it (was 8.4).
  • Clearance scan (scratchpad/issue-274/clearance-scan.py, over the gate's --verbose output, Arial/Liberation metrics): flags any side under 12px to the shape under a text and any two texts on one baseline closer than 16px. Result on all four SVGs: 0 findings across 218 text boxes; 16 single-glyph digits centred in 28px/40px badge chips are exempt by an explicit rule (a chip that narrow cannot give 12px on every side; the gate confirms none overflows) — clearance-scan2.log. First pass had found the six real items above plus the 48 chip sides — clearance-scan1.log.
  • Re-run on the committed tree: pnpm svg:lint OK (4 SVGs, 218 boxes, 0 findings); pnpm check under the lock VERDICT command-exit 0, 0 errors / 0 warnings / 0 hints (160 files) — check-r2.log; pnpm build under the lock VERDICT command-exit 0, 920 pages in 53.62 s, --dist prose / CJK / quotes / links all pass, tree clean afterwards — build-r2.log; pnpm seo:smoke pass (910 pages, 99450 links) — seo-r2.log. All four SVGs re-rendered at native size on light and dark (shots-274/svg/*.png) and looked at: columns separate, stop lines clear, last row line clear.

Acceptance notes

维护者速读(草稿)

改了什么。 新增一篇博文(八个语言版本、封面与一张示意图),回答「能否证明是谁打开了一条客户记录——包括 AI Agent」:在已发布的 17.7.0 上,一个人经 REST 的详情读取与一个 Agent 经 MCP get_record 的读取各在 sys_audit_log 留下一行 read;列表、未登记对象、被拒绝的读取一行都没有;并逐条核对一行查看记录永远不记的四件事(字段值、看到了什么、IP/UA、被拒读取)。三篇已发布文章各加一句回链并设 updated: 2026-10-26。

为什么改。 #267 日更第一批的第 10-26 档;此前没有任何文章覆盖读取审计及其范围边界。文中每个数字与日志行都来自 17.7.0 实测,文档引文逐行核对到标签。

风险与代价(含回滚)。 内容类改动,不触及站点代码;门禁与六视口浏览器检查全部通过。回滚只需 revert 这一个提交。注意:文章如实指出在默认 os dev 日志级别下只能看到 superseded 警告,以及 Agent 的行只记主体不记通道(需把 key 铸在专用身份上)——这两点是对读者的提醒,不是缺陷。

席位意见。 (留空)

你要做的。 档期日合并;若档期再变,先改 date 与三篇文章的 updated 再合并。


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

…an AI agent? record-view auditing on 17.7.0 (slot 2026-10-26)

New post in all eight locales (zh-Hant generated by pnpm gen:zh-hant) with an
English-text cover and diagram plus the zh pair. Proof: examples/app-crm at tag
@objectstack/cli@17.7.0 on the published 17.7.0 packages with
new AuditPlugin({ readAudit: { objects: ['crm_contact'] } }) in plugins; five
reads (REST detail by a person, MCP get_record by an API-key agent, a list, an
unlisted object, a refused read) left exactly two read rows in sys_audit_log.

Back-links with updated: 2026-10-26 on eu-ai-act-runtime-audit (8 locales),
when-ai-agent-deletes-production-database (8) and
objectos-agent-permission-boundaries (en, zh-Hans, zh-Hant regenerated).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
…d clear the diagram stop lines

Covers (en, zh): ledger row bands widened to x=14..642, columns at 28/150/212/392/512 with ids
shortened to four characters, so every column gap is at least 20px and every value sits at
least 14px inside its band; the two footnote lines moved up 4-6px for 14px bottom clearance.
Diagrams (en, zh): the three WHERE C, D AND E STOP lines shortened to keep 50px or more of
clearance to the card edge; the two ledger row cards grown from 110px to 116px so the last
line keeps 14px below it. Clearance scan over the gate's own --verbose widths: 0 findings
(16 single-glyph badge chips exempt by rule); svg:lint OK on all four; re-rendered at native
size on light and dark and looked at.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants