Repository navigation
security(plugin-audit): an activity row composed at write time may carry a changed field's values to a reader who can read the parent record but not that field (unmeasured; measure first) #21081
Description
Activity
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·security·priority:p2·domain:services·area:access·pm:queue. Measure first; a served value raises it to p1Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T06:01Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only.Why p2, for now.
- It is a suspicion read from source, with no
reach:measured yet. - PR fix(plugin-audit)!: an engine read of sys_activity returns only the rows whose parent record the caller can read #21069 merged at 2026-10-01T05:25Z, read at this write. So the measurement runs on
main, with the activity read gate in place. - Raise rule: if the measurement shows a field value served to a reader who may not read that field, the claim raises the card to p1 and says so here. That is the precedent's grade (security(approvals): the approval snapshot redaction narrows by
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964, the same class for approval snapshots, p1). - If nothing is served, the card closes with the reading.
Routing.
plugin-auditisdomain:services.Direction. It is the card's own scope, confirmed:
- one reader per field class (masked, capability-gated, not granted), with an unmasking reader as the control;
- if served, the safe side is the security service's own answer, at composition or at read, as security(approvals): the approval snapshot redaction narrows by
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 did; - ⛔ no second derivation of the masking rule in
plugin-audit.
Generated by Claude Code
- It is a suspicion read from source, with no
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-01T08:11Z
Session:session_01DiCSbmJrkzNhuEAier4VoJ
Account:os-bill(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-21081-activity-field-values
Worktree:objectstack-issue-21081
Domain:domain:services
Seat:domain:services#2(seat post #21118)
File surface: measure first, privately, then fix only if a value is served. If it is, the expected landing ispackages/plugins/plugin-audit/src/audit-writers.ts(composition) or the activity read path besideactivity-read-visibility.ts(read-time redaction through the security service's own answer), with tests underpackages/plugins/plugin-audit/src/and a changeset. ⛔ No second derivation of the masking rule inplugin-audit. ⛔ No edit topackages/plugins/plugin-security/src/security-plugin.ts(#21063 holds it). Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates--tier: no path-derived mandate; default tier, because the composition-or-read choice is a judgment)
Clause-②: no
Thread-read: 5925692878
Serial constraints cleared: I diffed the pushed branches of every openpm:dispatchedcard (29 cards, 26 branches) againstorigin/main, and read the file lists of all open PRs. No branch and no open PR touchespackages/plugins/plugin-audit/src/**. The open dependabot PR #21024 changes onlyplugin-audit/package.json. Thearea:accesscards in flight (#21087, #21086, #21062, #21052, #20987, #21063) are on other files. Today6f578888(PR #21069, the activity read gate) landed onplugin-audit, and the measurement runs on top of it.
Selection: P1 first, per the maintainer's order 「优先 p1 的卡片」. #21079 and #21080 are still serial behind other seats' in-flight work (seat post #21118). #21057 moved topm:blockedbehind #15193. This card is the nextbugby label order. #20965 is serial behind #21044 onanalytics-service.ts.Clause-②
no: if a fix lands, it narrows what an activity row serves to a reader who may not read a field. No accept set widens and no public surface grows.⚠️ Disclosure discipline: classes and positions only, in every GitHub text.
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21081,
"status": "done",
"branch": "claude/issue-21081-activity-field-values",
"pr": "#21152",
"session": "session_01DiCSbmJrkzNhuEAier4VoJ — the dispatching seat session (this run is its subagent)",
"premise_still_valid": true,
"summary": "MEASURED FIRST, AND SERVED: on a real boot (bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST, auth and AuditPlugin; rows written by the CRUD mirror from REST writes), each of the three reader classes — a field served masked, a capability-gated field with no mask, and a field a held permission set marks non-readable — was served that field's stored value by the activity rows about the parent, through the generic list and by-id doors. This held at every position: the tracked-change summary, the milestone summary, the record label and the recorded change. The data plane meanwhile served the same reader the field replaced or without the key. The unmasking control read stored values everywhere. Under the triage's raise rule this is the p1 trigger; that write is the seat's. THE CHANGE (draft PR #21152), read time and through the security service's own answer, mirroring the approval snapshot precedent. Composition time cannot know the reader and cannot see the permission-set class at all. A new sys_activity read middleware narrows the recorded change key by key. It serves the summary and the record label whole or drops them whole, by a declaration of their source parent fields that the writer now records in the row. The served-unmasked set is getReadableFields intersected with getQueryableFields, failing closed when the query-side answer is missing. Nothing in plugin-audit derives masking. Rows written before the declaration existed serve their text only to a reader served every field of the parent. App-written rows are served as written. After the fix, the same probe reads every class's values gone for its reader, the other classes still served, and the control unchanged.",
"tests": "All at final head eec3115, after merging current main twice; the second merge brought no plugin-audit source overlap. (1) pnpm --filter @objectstack/plugin-audit test: 29 files / 411 tests passed. (2) pnpm --filter @objectstack/plugin-audit typecheck: exit 0, check:test-typecheck OK. (3) Dogfood vitest --project isolated on the new activity-field-values pin plus the sibling activity-parent-read-gate pin: 2 files / 16 tests passed. Dogfood typecheck: exit 0. (4) New unit pin activity-field-redaction.test.ts, 19 cases: real engine, real SQLite driver, the real AuditPlugin mount, and a security-service double answering both contract members per reader. (5) New dogfood pin activity-field-values.dogfood.test.ts, 10 cases: one reader per class plus the control, armed in beforeAll by assertArmed (rows at rest carry every class; the data plane serves each reader its class as declared). ABLATIONS, every negative pin, all via scripts/ablation-replace.mjs (anchor hit once, landed by count and blob, restore proven blob==HEAD and git diff HEAD empty), unit legs re-run at eec3115 with identical counts: mount removed 10/19 red; query-side intersection removed 7 red (masked class only); text check off 8 red; key narrowing off 7 red; earlier-shape recognition off 1; projection augmentation off 1; milestone provenance off 4; tracked provenance off 6; label provenance off 1; fail-closed branch opened 2; declaration strip off 1. Each leg's other cases stayed green. Dogfood legs (dist-resolved): plugin-audit rebuilt, and scripts/ablation-dist-preflight.mjs proved the marker present in dist; the restore leg rebuilt and proved it absent with --absent on a clean whole tree. Mount removed: 6/10 red (all six per-class negatives; control and preservation green). Query-side intersection removed: 2/10 red (masked class only). One first dogfood mount leg was VOID: the declaration build refused the mutation (an import left unread), nothing ran, and it is not counted; it was re-run with a buildable mutation. LINT, proven narrowing: eslint --no-inline-config --format json over the 6 changed TS files reported 6 files, none ignored, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (0 parserOptions.project / projectService), so no untouched file's verdict can move. The repo-wide pnpm lint is CI's. CI on eec3115, read once: 14 completed (12 success, 2 skipped), 18 in_progress — in_progress, not awaited.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands at eec3115 (no paths; change set from merge base c6954d6): 95 families. All 95 were run with exit codes captured before any pipe, and all exited 0. The --ran reconciliation reads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded. Two were red on the way and fixed, not waived: check-system-context-census (the new middleware's system-context read wanted a row; added beside the sibling read gate's row, counts regenerated by gen:system-context-census) and check:query-options-erasure (the new test had added 4 cast sites; options now typed, test surface back at its 236 ceiling). check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 unrelated packages had no dist); built, then exit 0.",
"line_budget": "1193 changed lines (+1156 / -37) across 8 files at eec3115 vs merge base c6954d6, under the 5000 human-merge threshold. No skills/** and no governed surface in the diff.",
"files_changed": [
".changeset/21081-activity-field-values.md",
"content/docs/permissions/system-context.mdx",
"packages/plugins/plugin-audit/src/activity-field-redaction.test.ts",
"packages/plugins/plugin-audit/src/activity-field-redaction.ts",
"packages/plugins/plugin-audit/src/activity-read-visibility.ts",
"packages/plugins/plugin-audit/src/audit-plugin.ts",
"packages/plugins/plugin-audit/src/audit-writers.ts",
"packages/qa/dogfood/test/activity-field-values.dogfood.test.ts"
],
"deviations": [
"File surface widened by two paths. packages/qa/dogfood/test/activity-field-values.dogfood.test.ts: only the real security plugin tells a capability-gated field from a field no set grants, so the per-class pin needs a real boot; this is the precedent's dogfood shape. content/docs/permissions/system-context.mdx: the check-system-context-census gate requires a row for the new system-context read. No edit to plugin-security/src/security-plugin.ts.",
"Dispatch-prose conflict: the order said to pass changed paths to dispatch-gates.mjs; os-dev.md says to derive with no paths (the script reads the change set from the merge base). I followed os-dev.md. With no paths, the change set it derived was exactly the 8 committed paths.",
"Measurement probe: a temporary, never-committed probe file under packages/qa/dogfood/test/ produced the readings into the scratch dir and was deleted before the first commit that touched dogfood.",
"A read-only, blob-filtered clone of objectstack-ai/objectui at the pinned .objectui-sha went into the scratch dir, to check how the timeline consumes the activity columns (summary null-tolerant; recorded change and record label not read). No write.",
"A turbo-backed gate in the final union appended a managed block to AGENTS.md in this worktree (turbo 2.11.5 agent guidance). It was restored with git checkout HEAD, proven by disk blob == HEAD blob and an empty diff, and not committed. Reported as a finding below.",
"Merged origin/main into the branch twice (merge commits 93c2e68 and eec3115), with no source overlap, so that gates and suites read a current tree."
],
"mcp_calls": "0 — no MCP GitHub tool called.",
"api_writes": "3 — each one POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #21152; read-back: 10699 bytes sent and 10699 stored, identical); (2) label-write assign → POST /repos//issues/21152/assignees (os-bill; read-back matches); (3) this os-dev-report → POST /repos//issues/21081/comments. Not REST: 6 git pushes of the feature branch.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door — the generic list door on the activity stream, as a reader of each of the three classes, with a predicate over the stored activity text: row presence answered a value probe (rows on a hit, none on a miss), both before and after this fix. The predicate is evaluated at rest, before any redaction. A group or aggregate over the same columns is the same class and was not measured. The approval snapshot column on its generic door is plausibly the same family and was not measured. · evidence: probe readings in this dispatch's scratch; the precedent closed only its service door's free-text arm. · dedupe words: activity text predicate oracle · filter over redacted column row presence · generic door predicate snapshot column · sys_activity summary contains",
"class: a · reach: public door — the generic list door on the compliance ledger, as a reader whose sets grant that object read (the platform's viewer_readonly wildcard set) and also withhold a field of the parent: the ledger's create and update rows served the withheld field's stored values in their before/after snapshots, while the data plane served the same reader without the key. Measured on the same boot. Whether ledger readers are meant to be field-unrestricted is a policy question for the seat. · evidence: probe readings in this dispatch's scratch · dedupe words: audit log old_value new_value field level security · sys_audit_log snapshot withheld field reader · compliance ledger field values viewer_readonly",
"class: a · reach: named producer — turbo 2.11.5 (the dev-dependency group bump now on main, 840ec9d), run by a turbo-backed check:* gate in an agent session, appended a 'turborepo-agent-rules' managed block to AGENTS.md (a Tier H governed surface). turbo.json carries no agentGuidance opt-out. An agent's git add -A would carry it into a PR and turn that PR governed. · evidence: this worktree at eec3115 showed AGENTS.md +11 lines (mtime 10:00:16Z) right after the gate union; restored. · dedupe words: turbo agentGuidance AGENTS.md · turborepo-agent-rules block · turbo 2.11 writes AGENTS.md · governed surface modified by tool",
"carrier: 承接者:无 · noted, not filed. A tracked-change summary renders a referenced record's title, resolved at write time. This fix judges the parent's own lookup field, not the referenced record's readability or the readability of its title field. Unmeasured. It is not in PR #21152's acceptance notes (the body is written once); the seat may add the line there.",
"carrier: 承接者:无 · noted, not filed (in PR #21152's acceptance notes). The served-unmasked composition (read projection intersected with the query-side answer, failing closed) now exists in two consumers: the approval snapshot and the activity redaction. Hoisting it to one shared helper would remove a drift seam."
]
}- addedpriority:p1High: required for production / M2High: required for production / M2and removedpriority:p2Medium: important, M3Medium: important, M3
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsRaise to
priority:p1(triage's raise rule), and PM review — PR #21152: ACCEPT. Landing: ordinary queue once every check on headeec3115fis greendomain:servicesseat 2 (#21118) ·session_01DiCSbmJrkzNhuEAier4VoJ· 2026-10-01T10:19Z. Reviewed-by: this seat. Implemented-by: os-dev subagent, same session (report on this card, PR body).⚠️ Classes and positions only.Raised
priority:p2→priority:p1, in this act. Triage's rule (5925692878) reads: "if the measurement shows a field value served to a reader who may not read that field, the claim raises the card to p1 and says so here." The dev measured it served on a real boot (bootStack with the real security plugin, SQL driver, REST and audit plugin). All three reader classes (masked, capability-gated without a mask, not granted) were served the field's stored value at every position: the tracked-change summary, the milestone summary, the record label and the recorded change, through the list and by-id doors. The unmasking control was served stored values. The readings are private to the dispatch.Checklist, read on GitHub rather than from the report:
- Shape: draft, base
main, first lineFixes #21081,Clause-②: no(matches the claim). The body carries classes only, so disclosure discipline holds. - Path surface: six files in
plugin-audit/src/, apatchchangeset, and two declared widenings beyond the claim.packages/qa/dogfood/test/activity-field-values.dogfood.test.ts: only the real security plugin distinguishes a capability-gated field from an ungranted one. This is the precedent's dogfood shape.content/docs/permissions/system-context.mdx: census counts 109 → 110, whichcheck-system-context-censusrequires for the new system-context read.- Both are accepted.
check-governed-merges.mjs --pr 21152: not governed, 1193 changed lines (≤ 5000). No edit tosecurity-plugin.ts.
- Diff, read in full (source files):
- A new
sys_activityread middleware (find/findOne, non-system context) runs after the parent-record read gate. - The recorded change is narrowed key by key to the fields the security service serves unmasked:
getReadableFields∩getQueryableFields, which fails closed when the query-side answer is missing. - The summary and record label are served whole or dropped whole. That rests on a declaration the writer now records, which the renderers return from the same loop that renders the text, so it cannot drift.
- Rows in the earlier shape serve their text only to a reader who is served every field. An unexpected failure strips the value-bearing columns.
- No masking rule is derived in
plugin-audit, which holds triage's direction. resolveServedFieldsis line-for-line the approval snapshot'sresolveReadableSnapshotFields(plugin-approvals/src/payload-redaction.ts). That includes its pass-through whengetReadableFieldsitself throws, which both consumers inherit. The dev's hoisting note below would give that shared shape a single home.
- A new
- Evidence accepted:
- the plugin suite (411) and typecheck are green;
- a 19-case unit pin (real engine, real SQLite) and a 10-case dogfood pin, armed by
assertArmed; - eleven unit ablations, each turning only its own cases red. The dogfood ablations ran against a rebuilt
distthat was proven present and then absent. One void leg was declared and re-run; - 95 of 95 derived gates exit 0, and the two red on the way were fixed rather than waived.
- CI on head
eec3115fat this write: 22 success, 2 skipped, 9 in progress, 0 failed.
Out-of-scope findings:
- filed — a predicate over the stored activity text answers by row presence (the generic list door, all three classes, before and after this fix). The card number follows in the next comment.
- filed — the compliance ledger's create/update snapshots serve a withheld field's stored values to a reader whose sets grant the ledger read. Whether ledger readers are meant to be field-unrestricted is the policy question the card carries. The card number follows in the next comment.
- dropped —
turbowriting a managed block intoAGENTS.mdis already tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146 (open). - dropped — a tracked summary renders a referenced record's title, and this fix judges the parent's lookup field, not the referenced record's readability. It is unmeasured, so it does not meet the filing gate.
- Acceptance notes — hoist the served-unmasked composition into one helper shared by the approval snapshot and the activity redaction.
Generated by Claude Code
- Shape: draft, base
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsFiled from this card's out-of-scope findings ·
domain:servicesseat 2 (#21118) ·session_01DiCSbmJrkzNhuEAier4VoJ· 2026-10-01T10:22Z · ⛔ Not a claim.- Finding 1 (a predicate over the stored activity text answers by row presence) → security(plugin-audit): a filter over the stored activity text answers by row presence: the generic list door evaluates the predicate at rest, before #21081's read-time redaction, so a reader can probe a field value it is not served #21154.
- Finding 2 (the compliance ledger's snapshots serve a withheld field's value to a ledger reader) → security(plugin-audit): the compliance ledger's create/update rows serve a withheld field's stored value in their before/after snapshots to a reader whose sets grant the ledger read, while the data plane serves that reader without the key #21155.
Both are bare, for triage.
Generated by Claude Code
- added 4 commits that reference this issue
on Oct 7, 2026
Filing gate: ① a product defect, filed under the possible-data-disclosure exception, whose first point is measure reach first. No⚠️ Disclosure discipline: positions and caller classes only.
reach:is measured yet.Source: #20833's dev report (
5924706600,out_of_scope_findings, thecarrier:entry, "noted, not filed"). The at-tier contract review of PR #21069 (5924857517, ③) escalated it: "a data-exposure class with no carrier yet — the seat should give it a card or a row in the close-out rather than acceptance notes alone". It is not the same mechanism as the close-out family card filed beside it, so it gets its own card. Filed by thedomain:servicesexecution seat (#6021,session_01XY5uCwTjZj7884yYtyur4H). ⛔ Not a claim.The suspicion (by class; ⛔ not measured)
packages/plugins/plugin-audit/src/audit-writers.tscomposes the activity row at write time, as the system. Its human-readable summary and its recorded details can carry the changed fields' values. The writer masks credential fields (secret, andpasswordoff better-auth objects) and drops virtual ones. It does not apply the reading caller's field-level answer, which it cannot know at write time.getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 (PR fix(approvals): a snapshot field the reader is served masked is no longer served as stored (#20964) #20993) closed the same class for approval payload snapshots, at read time and through the security service's own answer.Direction (⛔ not a ruling)
getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964's redaction does.plugin-audit.Reader who acts
Triage (grade and route;
plugin-auditisdomain:services), then this lane's seat for the measurement.Dedupe
mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 (closed) is the same class for approval snapshots: the precedent.maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995 (open) are the zero-set caller's field answers at other doors.collectMaskedReadFieldsand objectui'sMASKED_FIELD_TYPESeach own a copy of one fact #20141, A field denied by FLS (readable:false) is exactly recoverable from a readable formula that references it — measured on showcase_project.budget #9562, spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), andmaskingRulewas pruned as dead in 2026-06 #8993, finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603, A permission set accepts a hierarchyreadScopebesideviewAllRecords: true, never reads it, and emits no diagnostic — the declaration materialises and a capability census counts it as coverage #16870, [permissions] 行级读可见范围无法按业务字段收窄:viewAllRecords 全有/全无两档之间缺共享规则 #4376 and finding: the #7929 read-scope disclosure is NOT analytics-only — an RLS$fieldfilter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988 (closed) are other masking or FLS defects.Dedupe words:
activity summary masked field value·activity row field level·write-time summary field permission·audit activity before after valuesGenerated by Claude Code