Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21388-withheld-only-activity-row
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21388-withheld-only-activity-row

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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.

  • An update row is one whose stored change has both sides (metadata.old and metadata.new are records). A create (old null) and a delete (new null) keep their rows.
  • "Had keys" reads the STORED change, never the redacted one. A row whose stored change is empty on both sides (an update that touched only internal fields, which the writer omits) is empty for every reader. It is unaffected.
  • "Withheld" is the answer the redaction already narrows by: 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.
  • Every face agrees. The rule is a WHERE, not a post-read drop, and it is built the way the parent-record read gate builds its own. A SYSTEM pre-scan of the rows the query would touch (caller's WHERE and order, bounded at the gate's 2,000) judges each row. The withheld ids are ANDed out with { id: { $nin: WITHHELD } }, on find, findOne, count and aggregate. So the list's total, its pages and hasMore, 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, and AuditPlugin already 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.ts gains a header paragraph that points at it. No audit-plugin.ts edit, no audit-writers.ts edit, no writer change.

Measured first, on main at 6d67ad5ec, at the HTTP door

Real boot (showcase, SecurityPlugin with the platform sets plus one object-level sys_activity read set, AuditPlugin). The setup is the card's: one org whose members are the platform admin, a member holding the activity read set (member_default otherwise), and a colleague who signs in twice through POST /auth/sign-in/email. The reads are of the colleague's identity record's activity rows.

Face, as the member main (6d67ad5ec) this branch
GET /data/sys_activity filtered to the record: rows / total 4 / 4. Both sign-in stamp rows are served with an empty change, the summary, the actor and the timestamp 1 / 1 (the create row)
$top=1 walking $skip stamp rows on pages 3 and 4; total 4 on every page one page; total 1
GET /data/sys_activity/ID for each stamp row 200 404
POST /data/sys_activity/query, filtered 4 rows, total 4 1 row, total 1
POST /data/sys_activity/query, grouped count by type created 1, updated 3 created 1
the admin, every face above 4 rows, the stamp rows carrying last_login_at unchanged
  • Cursor: the data door has none. The engine tombstones the cursor query key, and the door pages by $top / $skip and reports total / hasMore. Those are measured above.
  • Activity-feed route: none outside the data door. The feed service was removed (ADR-0052 §5), and the timeline reads sys_activity through the data door.
  • The NOT MEASURED premise, measured. A sign-in DOES move the identity row's updated_at: both sign-ins moved it. The member's direct read of the colleague row serves updated_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.
  • The colleague's sign-up also writes a withheld-only update (password_changed_at). On main it 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 main at the same door, on the same colleague. A lockout threshold was applied through applyConfigPatch, the way the settings service applies one.

Write Recorded keys Served to the member on main
failed sign-in (the counter bump) failed_login_count yes, as an empty row (once per failed attempt)
successful sign-in after a failure (the counter reset) failed_login_count yes, as an empty row
lockout (the attempt that reaches the threshold) failed_login_count, locked_until yes, as an empty row
password change (POST /auth/change-password) password_changed_at yes, as an empty row
MFA-required stamp (the writer's patch shape, written as the system) mfa_required_at yes, as an empty row
ban (set, with reason and expiry) banned, ban_reason, ban_expires yes, WITH banned. Not in this class: the deactivation flag is directory status, served by design

The 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 banned only.

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:
    • the card's three pins: the member gets no row, the admin gets the row with its change, a mixed update keeps the served key;
    • the empty-for-everyone control, which the writer produces from an internal-only update and the scene asserts is empty at rest;
    • a create that keeps its row when every key is withheld;
    • the count pin, plus aggregate, a one-row page walk and findOne, all agreeing with find;
    • a system read;
    • the predicate's shapes;
    • the bound: $nin under it, $in of 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 with name only. The admin keeps every row with its change, and its total is the member's plus the withheld rows. The setup is guarded by armed checks.
  • Two existing pins the ruled behaviour made false keep their intent (activity-field-redaction.test.ts):
    • The earlier-mirror-shape row now carries a MIXED change, so its "restricted reader keeps the row without text, narrowed key by key" intent still measures that. A change withheld whole is the new file's subject.
    • The fail-closed reader (served no field) still keeps no value composed from a field. It keeps the create row and is now withheld every update row whose change had keys.

Ablations

Each was run from committed state e08662d24 through scripts/ablation-replace.mjs. Each mutation was proven on disk (anchor x1 to x0, replacement x0 to x1, blob changed), and each was restored with git diff HEAD empty and the disk blob equal to the HEAD blob (85e460841688). The subject is imported by relative path (./audit-plugin.js), so these runs read src/ and no dist/ leg applies.

Mutation Pins that went red
the rule removed (andIntoWhere of the filter skipped) the member pin; count; aggregate; findOne; "the member is served exactly the other rows"; the create pin (6 red)
the rule applied to an empty-for-everyone row (the keys.size === 0 guard defeated) the empty-for-everyone control; "exactly the other rows"; aggregate; the predicate's shape case (4 red)
the count left uncorrected (the rule on find / findOne only) count; aggregate (2 red). The page walk stays green: pages are find

Verification (at bc2b06fb3, after merging origin/main at ceb4a939b)

  • pnpm --filter @objectstack/plugin-audit test: exit 0, 37 files, 600 tests.
  • pnpm --filter @objectstack/plugin-audit typecheck: exit 0. It includes check:test-typecheck, and the new test file is in tsconfig.test.json's program (--listFiles count 1).
  • pnpm --filter @objectstack/dogfood typecheck: exit 0. The new dogfood file is in the program (--listFiles count 1).
  • Dogfood, every file that reads sys_activity (8 files, the new one included): exit 0, 78 tests, on a rebuilt dependency closure. The suite resolves @objectstack/plugin-audit through dist/, 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 --ran reconciles 96/96 with 0 NOT-MEASURED (a derived zero, every exit code recorded).
    • Two first runs answered PREREQUISITE NOT MET (exit 3) for packages outside this diff's closure that had no dist/ (check:skill-examples, check:dual-build-cjs-loads). They were re-run green after those packages were built.
    • check:query-options-erasure exceeded a 300 s per-command cap once, and was re-run green in 265 s.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 5 changed TypeScript files reports 5 files, 0 errors, 0 warnings, none ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules; --print-config shows project null). So this diff cannot move a verdict on an untouched file. The full pnpm lint is 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, on count and aggregate too, and the "Get" column names the redaction's own pre-scan. check:system-context-census is green. A grep of content/docs/** (outside releases/) and skills/** for the activity stream's per-reader visibility found no other sentence this makes false. record-view-auditing.mdx and audit-service.mdx describe the ledger, or only name the object.

Acceptance notes

  • Cost. Every non-system sys_activity read 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 by object_name and record_id) scans a handful of rows. A broad read scans up to 2,000 rows' metadata.
  • Broad reads past the bound. A broad read whose pre-scan reaches 2,000 rows is now answered from the judged window only. Its total cannot 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 by object_name and record_id).
  • The compliance ledger door, NOT MEASURED. sys_audit_log's field redaction narrows old_value / new_value the 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.
  • A milestone's 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's type, which names the withheld field's transition. A withheld-only one is withheld by this PR. This is an inference from reading audit-writers.ts, not a measurement. Carrier: none.
  • File surface. The claim's two read-side files and tests beside them, the dogfood route pin the claim allows, the changeset, and the one docs row the dispatch's Docs section asks for.

Generated by Claude Code

claude added 5 commits October 2, 2026 13:35
… 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>
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/activity-read-visibility.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json bdd3654f299bcc4486adb4fd57396155e5897ab8.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/activity-read-visibility.ts) — pages documenting those are invisible to this run
  • 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 bdd3654f299bcc4486adb4fd57396155e5897ab8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 34546c3d9d1b406e483236e09cfa4af4301b066d — the merge of head f58f6bdfb985d418a62b6c2950cd1a1b3c48d5a0 into base bdd3654f299bcc4486adb4fd57396155e5897ab8, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs bdd3654f299bcc4486adb4fd57396155e5897ab8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 2, 2026 15:24
…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 15:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 15:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 3bddd4a Oct 2, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21388-withheld-only-activity-row branch October 2, 2026 16:07
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/l tests tooling

Projects

None yet

2 participants