fix(plugin-audit): an update activity row whose every recorded change is withheld from the reader is withheld as a row, on every listing face (#21388) - #21427
Conversation
… is withheld from the reader is withheld as a row (#21388) The rule is a WHERE built from a SYSTEM pre-scan on find, findOne, count and aggregate, so a list's total, its pages and a grouped count agree with the rows served. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…read face (#21388) The two redaction pins the ruled behaviour made false keep their intent: the earlier-shape row carries a mixed change, and the reader served no field is withheld every update row whose change had keys. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…an org peer and an admin (#21388) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ame the withheld-update row rule (#21388) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…thheld-only-activity-row
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 34546c3d9d1b406e483236e09cfa4af4301b066d && git checkout 34546c3d9d1b406e483236e09cfa4af4301b066d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bdd3654f299bcc4486adb4fd57396155e5897ab8 f58f6bdfb985d418a62b6c2950cd1a1b3c48d5a0 && git checkout -B drift-repro bdd3654f299bcc4486adb4fd57396155e5897ab8 && git merge --no-ff f58f6bdfb985d418a62b6c2950cd1a1b3c48d5a0
node scripts/docs-audit/affected-docs.mjs --json bdd3654f299bcc4486adb4fd57396155e5897ab8
|
…e by the pre-scan's bound (#21388) Past the bound, a broad read is answered from the judged window for every reader, administrators included. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…thheld-only-activity-row
Fixes #21388
Clause-②: no
What changes
An activity-stream (
sys_activity) row that records an UPDATE, whose stored change had keys, every one of which the reader is withheld, is now withheld from that reader as a row. Before, the field redaction narrowed the change key by key, and the row still reached the reader with an empty change, a summary, an actor and a timestamp. That is how an org peer read when each of a colleague's sign-ins happened.metadata.oldandmetadata.neware records). A create (oldnull) and a delete (newnull) keep their rows.internalfields, which the writer omits) is empty for every reader. It is unaffected.resolveServedFields, the security contract's read projection intersected with its query-side answer. One answer serves both, so a row is withheld exactly when the redaction would leave its change empty. A reader the service gives no answer for is narrowed by neither. ⛔ No object or field is named in the rule.{ id: { $nin: WITHHELD } }, onfind,findOne,countandaggregate. So the list'stotal, its pages andhasMore, a by-id read and a grouped count agree with the rows served. A pre-scan that reaches its bound fails closed, the way the read gate's does. The read is answered from the judged rows only ({ id: { $in: KEPT } }), with one warn naming the remedy. A pre-scan failure denies the read.Where it sits. The rule lives in
activity-field-redaction.ts, in the redaction's own middleware. That middleware already holds the per-read security resolver, andAuditPluginalready registers it after the read gate's, so its pre-scan reads the WHERE the gate has already narrowed. The pre-read step and the post-read redaction share ONE per-read served-fields answer.activity-read-visibility.tsgains a header paragraph that points at it. Noaudit-plugin.tsedit, noaudit-writers.tsedit, no writer change.Measured first, on
mainat6d67ad5ec, at the HTTP doorReal boot (showcase,
SecurityPluginwith the platform sets plus one object-levelsys_activityread set,AuditPlugin). The setup is the card's: one org whose members are the platform admin, a member holding the activity read set (member_defaultotherwise), and a colleague who signs in twice throughPOST /auth/sign-in/email. The reads are of the colleague's identity record's activity rows.main(6d67ad5ec)GET /data/sys_activityfiltered to the record: rows /total$top=1walking$skiptotal4 on every pagetotal1GET /data/sys_activity/IDfor each stamp rowPOST /data/sys_activity/query, filteredtotal4total1POST /data/sys_activity/query, grouped count bytypelast_login_atcursorquery key, and the door pages by$top/$skipand reportstotal/hasMore. Those are measured above.feedservice was removed (ADR-0052 §5), and the timeline readssys_activitythrough the data door.updated_at: both sign-ins moved it. The member's direct read of the colleague row servesupdated_at, and it equals the latest stamp. So the LATEST sign-in time stays readable through the direct read, until the next write of that row. That field is the record read's, not this card's. What this PR closes is the HISTORY.password_changed_at). Onmainit was served to the member as an empty row too.The raise-rule measurement (for the seat's re-grade)
The other withheld-only update classes the writers produce were measured on
mainat the same door, on the same colleague. A lockout threshold was applied throughapplyConfigPatch, the way the settings service applies one.mainfailed_login_countfailed_login_countfailed_login_count,locked_untilPOST /auth/change-password)password_changed_atmfa_required_atbanned,ban_reason,ban_expiresbanned. Not in this class: the deactivation flag is directory status, served by designThe lockout class, the failed-sign-in attempts under it, the password change and the MFA stamp are withheld-only update classes whose timing is sensitive beyond sign-ins. Per triage's raise rule, that reads p2. The re-grade is the seat's. On this branch, all of them except the ban are withheld from the member as rows. The ban stays served, with
bannedonly.Pins
packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts(19 cases). It uses a real engine, SQLite,AuditPlugin, rows written by the real CRUD mirror, and a security double standing in for the field answer. It covers:internal-only update and the scene asserts is empty at rest;aggregate, a one-row page walk andfindOne, all agreeing withfind;$ninunder it,$inof the judged-kept rows at it, with the warn; the pre-scan reads as the system, in the caller's order.packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts(6 cases, real boot, HTTP door). Two sign-in stamps, a failed-sign-in counter bump and a mixed rename. The member is served none of the withheld-only rows on list /total/hasMore, on a one-row page walk, by id (404) and on the query and grouped-count faces. The mixed row is served withnameonly. The admin keeps every row with its change, and itstotalis the member's plus the withheld rows. The setup is guarded by armed checks.activity-field-redaction.test.ts):Ablations
Each was run from committed state
e08662d24throughscripts/ablation-replace.mjs. Each mutation was proven on disk (anchor x1 to x0, replacement x0 to x1, blob changed), and each was restored withgit diff HEADempty and the disk blob equal to the HEAD blob (85e460841688). The subject is imported by relative path (./audit-plugin.js), so these runs readsrc/and nodist/leg applies.andIntoWhereof the filter skipped)findOne; "the member is served exactly the other rows"; the create pin (6 red)keys.size === 0guard defeated)find/findOneonly)findVerification (at
bc2b06fb3, after mergingorigin/mainatceb4a939b)pnpm --filter @objectstack/plugin-audit test: exit 0, 37 files, 600 tests.pnpm --filter @objectstack/plugin-audit typecheck: exit 0. It includescheck:test-typecheck, and the new test file is intsconfig.test.json's program (--listFilescount 1).pnpm --filter @objectstack/dogfood typecheck: exit 0. The new dogfood file is in the program (--listFilescount 1).sys_activity(8 files, the new one included): exit 0, 78 tests, on a rebuilt dependency closure. The suite resolves@objectstack/plugin-auditthroughdist/, so it was rebuilt first.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 96 families derived. All 96 ran with exit 0, and--ranreconciles 96/96 with 0 NOT-MEASURED (a derived zero, every exit code recorded).PREREQUISITE NOT MET(exit 3) for packages outside this diff's closure that had nodist/(check:skill-examples,check:dual-build-cjs-loads). They were re-run green after those packages were built.check:query-options-erasureexceeded a 300 s per-command cap once, and was re-run green in 265 s.eslint --no-inline-config --format jsonover the 5 changed TypeScript files reports 5 files, 0 errors, 0 warnings, none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules;--print-configshowsprojectnull). So this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Docs
content/docs/permissions/system-context.mdx, the plugin-audit row of the census: the "Lose" column now names the withholding of a withheld-only update row, oncountandaggregatetoo, and the "Get" column names the redaction's own pre-scan.check:system-context-censusis green. A grep ofcontent/docs/**(outsidereleases/) andskills/**for the activity stream's per-reader visibility found no other sentence this makes false.record-view-auditing.mdxandaudit-service.mdxdescribe the ledger, or only name the object.Acceptance notes
sys_activityread now runs a second bounded SYSTEM pre-scan (id,object_name,metadata), the first being the read gate's. The read is skipped when no security service is wired. A record timeline (scoped byobject_nameandrecord_id) scans a handful of rows. A broad read scans up to 2,000 rows'metadata.totalcannot exceed the window, for every reader the security service answers, administrators included. Before, the read gate's truncation kept rows beyond the window when their parent was judged readable inside it. Both are fail-closed; this one is narrower, because the withheld-update judgement is per row, not per parent. The warn names the remedy (scope byobject_nameandrecord_id).sys_audit_log's field redaction narrowsold_value/new_valuethe same way. A ledger reader without the audit capability, holding object-level ledger read, would plausibly be served a withheld-only update's ledger row with empty snapshots. This is an unexercised inference: no ledger read was made here, and the ruling scopes this card to the activity stream. Carrier: none.type, NOT MEASURED. A mixed update that fires an activity milestone keyed on a field the reader is withheld would be served with the milestone'stype, which names the withheld field's transition. A withheld-only one is withheld by this PR. This is an inference from readingaudit-writers.ts, not a measurement. Carrier: none.Generated by Claude Code