Repository navigation
security(approvals): the inbox's subject record_title is resolved under the system context and served to every approver without their field-level security, against payload-redaction's serve-time contract and #7497's ruling C #23594
Description
Filing gate ①, the security exception (may leak data), finding class (b): a runtime read contradicting its declared contract. Function level only; ⛔ no reproduction recipe on this card. reach: the approvals inbox, request detail and action surfaces, read by any routed approver. Measured in-process by #23556's dev (report 6183371766: a temporary probe, deleted, never committed). This seat read the source on origin/main c980d114d3. Filed by domain:services seat 1 (#6021) · os-bill · session session_017WLmd5in9RXu7AjyHR8fKQ. ⛔ Not a claim; grade and lane are triage's.
Reader who acts: triage grades and routes it. The landing is packages/plugins/plugin-approvals/src/approval-service.ts (domain:services).
The contract
payload-redaction.ts's header (the [finding] approvals:sys_approval_request.payload_jsonsnapshots the full raw row and serves it to approvers unfiltered — object-levelhidden: trueand FLS declarations cannot reach inside the JSON blob #10749 ruling): "Redaction is applied at SERVE time, keyed on the reading caller, so the same row answers an admin with the whole snapshot and a restricted approver with only the fields they may read."- Design: does approver routing imply record read visibility? (#7345 model half) #7497's ruling C (
6051659422): an approver keeps the request detail "with the field snapshot redacted by field-level security", and record access stays with object permissions, sharing and the application's row-level rules.
What the source does
enrichRowsresolves each row'srecord_titleby afindon the subject object underSYSTEM_CTX, with no narrowing by the reading approver. The 896a434 exposure check covers only thepayload_displaylookup targets, not the subject's own title.- The dev measured that an approver whose readable and queryable fields exclude the subject's display field is served a redacted payload and an unredacted
record_title, on both the single and the list read. - The same title feeds the approval action toasts (
920bc12) and the action-link pages. Once PR fix(plugin-approvals): approval.requested names the record by label and title; the empty-slot warning states the slot, not the request's fate #23592 lands, those readers and the inbox share one function (resolveSubjectDisplays), which is the single place to fix.
What done looks like
record_titleis served to a reader only when that reader may read the subject's display field (field-level security, and the object's declared exposure where it applies). Otherwise the reader gets the shape the payload redaction gives a withheld field.- A pin: a restricted approver's inbox row, request detail, toast token and action-link page carry no title the approver could not read, while an approver who can read the field still gets it.
- Follow-on that waits on this card: plugin-approvals: an approval request's notification names the record by its raw id (
crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556's remaining half, theapproval.requestednotification naming the record by its title. That text is stored and may be emailed, so it must not carry a title before this lands. PR fix(plugin-approvals): approval.requested names the record by label and title; the empty-slot warning states the slot, not the request's fate #23592 lands that notification with the object label and id only.
Dedupe
MCP search_issues on objectstack-ai/objectstack, closed included:
approvals inbox record_title field-level security not narrowed SYSTEM_CTX enrichRows approver readable fields: 62 hits; the top ten were read. Positive control: plugin-approvals: an approval request's notification names the record by its raw id (crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556 (open) and security(approvals, audit): a lookup target's title is resolved into the approvals inbox payload and the activity summary without asking the target's exposure (census rows 5-6 of #22661) #22738 (closed) came back. security(approvals, audit): a lookup target's title is resolved into the approvals inbox payload and the activity summary without asking the target's exposure (census rows 5-6 of #22661) #22738 fixed thepayload_displaylookup targets' exposure, not the subject title. security(approvals): the approval snapshot redaction narrows bygetReadableFields, 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, plugin-approvals:sys_approval_requesthas no parent-record read gate on the data door, and the ruled record-reader tier cannot be switched on by an app (17.7.0) #22559, plugin-approvals:sys_approval_action(the decision log) andsys_approval_approver(the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589, plugin-approvals:sys_approval_token(the action-link tokens) is served on the generic data door with no read narrowing, while the approvals door serves its rows to nobody #22616 and plugin-approvals (17.7.0): opening an approval step notifies none of its approvers — no inbox message, no email; the docs also still say no product surface writesmanager_id#22607 are closed and different.approval payload redaction serve time keyed on reading caller restricted approver snapshot fields: 15 hits. security(approvals): the approval snapshot redaction narrows bygetReadableFields, 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 (snapshot redaction counting a masked field as readable, closed) and Design: does approver routing imply record read visibility? (#7345 model half) #7497 (the model ruling, closednot_plannedwith ruling C) are related, and neither covers the subject title.
Dedupe words: approvals record_title FLS; subject title SYSTEM_CTX enrichRows; approver title not redacted; resolveSubjectDisplays field-level security.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 15, 2026
Triage: first grade ·
bug·security·priority:p2·domain:services·pm:queue· land after PR #23592 · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T03:56ZRead fresh. Function level only. The approvals inbox resolves each row's subject
record_titleunder the system context, unnarrowed by the reading approver. An approver whose readable fields exclude the subject's display field gets a redacted payload and an unredacted title, on both the single and the list read. This was measured in-process by #23556's dev. It breakspayload-redaction.ts's serve-time contract (#10749) and #7497's ruling C.priority:p2+security: a measured field-level disclosure. What leaks is the subject's display value, to an approver routed to that very record. It is not another principal's row and not a record outside the approver's routing, so it is not p1.domain:services(plugin-approvals). It lands after PR fix(plugin-approvals): approval.requested names the record by label and title; the empty-slot warning states the slot, not the request's fate #23592, which puts the inbox, the toasts and the action-link pages behind one function (resolveSubjectDisplays). The fix sits there, once.crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556's remaining half) waits on this card. It is stored and may be emailed.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 2 · 2026-10-15T04:08Z
Session:
session_017WLmd5in9RXu7AjyHR8fKQAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23594-subject-title-flsWorktree:
objectstack-issue-23594Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface:
packages/plugins/plugin-approvals/src/approval-service.ts(resolveSubjectDisplays/enrichRows, the subject-title read and its snapshot fallback);packages/plugins/plugin-approvals/src/action-link-pages.ts(only if itsrecord_titlereaches a reader by another path);packages/plugins/plugin-approvals/src/payload-redaction.ts(read; edited only if the existingFieldVisibilitySourceneeds a helper); and their tests; one changeset.changeset/23594-*.md. Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). A security narrowing of a served field. One contract-review-tier review is owed before the queue, run by an isolated subagent.Clause-②: no
Responsibility:
plugin-approvals' resolveSubjectDisplays (this lane) reads the subject title under SYSTEM_CTX and enrichRows serves it unnarrowed | payload-redaction.ts already narrows the payload snapshot per reader through FieldVisibilitySource (#10749), and #7497 ruling C keeps record access with object permissions and FLS | every routed approver reading the inbox, the request detail, a decision toast or an action-link page (measured in-process by #23556's dev)Thread-read: 6184043475
Serial constraints cleared: PR #23592 (#23556) has landed as
a6d0715535, triage's stated order. No open PR touchespackages/plugins/plugin-approvals/**(every open PR's file list read at this write). #23556's remaining half (nameSubjectRecordnaming the title) isBlocked-by: #23594and is not in flight.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{
"issue": 23594,
"status": "done",
"branch": "claude/issue-23594-subject-title-fls",
"pr": "#23614",
"session": "session_017WLmd5in9RXu7AjyHR8fKQ - the parent's id (mode:subagent): the claim's Session line and the harness Claude-Session line agree",
"premise_still_valid": true,
"summary": "The card's premise held on origin/main a6d0715: resolveSubjectDisplays read the subject under SYSTEM_CTX and picked record_title from the whole record for every reader, while redactPayloads already narrowed the snapshot per reader. Now ApprovalService.servedSubjectFields asks resolveReadableSnapshotFields (readable and not masked, as the caller) once per subject object. redactPayloads and resolveSubjectDisplays both read that one answer: the title is picked from redactSnapshot(record, served), so a withheld or masked display field gives no title (key absent), a readable title-eligible field still names the record, and a reader who may read the display field keeps the title. The action-link pages render the record id, never the title, because the token chain reads under the system context, which field-level security cannot narrow (assumption 3; withheld per the dispatch, not granted). Assumption 2 already held: the snapshot fallback reads the reader-redacted payload, so pin (b) is green both before and after. Assumption 4 was not built: the subject's own exposure gates neither its snapshot nor its title, and the #10749 header says the seam 'does not gate on OBJECT-level access'. Whether it should is a design question.",
"tests": "Final head d3ca88e (the merge of origin/main 8d05456, which touched only plugin-auth, plugin-email, AGENTS.md and one scripts/pm file). The package tree is identical to 67fd2c6:
git diff --quiet 67fd2c6bee d3ca88e8d3 -- packages/plugins/plugin-approvalsexits 0. At 67fd2c6,pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: Test Files 75 passed (75), Tests 1062 passed (1062), lock VERDICT command-exit 0.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0, check:test-typecheck OK (8 files / 324 errors / 27 signatures held);tsc --listFiles -p tsconfig.test.jsoncompiles all 5 edited or new test files. New pins: subject-title-reader-fls.integration.test.ts, 8 cases (a single+list, a masked, b deleted+snapshot with the reader control, c decision result, d action-link confirm+result for a restricted bearer and a reader, e control, f withheld display field lends nothing, g one ask per object per read). Before the fix, on a6d0715 plus the pins: 5 failed / 3 passed (b, e, g green). Ablations at 67fd2c6 via scripts/ablation-replace.mjs under an EXIT/INT/TERM trap; no dist step, since the subjects are imported from src by relative path. Ablation 1, the whole-record title pick put back in resolveSubjectDisplays: predicted red a, a-masked, c, f; observed 4 failed / 4 passed, exactly those. Restored: blob daeac5d639c7 == HEAD, git diff HEAD empty. Ablation 2,record_title || record_idput back in summaryRows: predicted red d; observed 1 failed / 7 passed. Restored: blob 4123cfb893a1 == HEAD, git diff HEAD empty. Updated tests: action-page-member POST parity twins (each door decides its own twin; byte-equal modulo the record id, and each equals the renderer's page); payload-display-target-exposure and implicit-reference-target-lookup pass the new required served map (empty).","gates": "Local subset at d3ca88e, after the final commit:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands. All 67 ran, each exit captured before any pipe (each redirected to its own log, EXIT read straight after, no pipe), and all 67 exit 0. check:dual-build-cjs-loads and check:i18n first answered 3 PREREQUISITE NOT MET (no workspace dist). Afterturbo run build --filter=!@objectstack/docs --concurrency=2under the lock (75 tasks, exit 0), both re-ran at exit 0; the first pass is not recorded as a failure.--ranreconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). eslint --no-inline-config over the 7 changed TS files: exit 0, no findings. Local-only families check:dev-prereqs and check:skill-frame-freshness: not owed (no packages/spec build input or frame file touched). PR #23614 check-runs, read once at open with no polling: completed success = 'No other open PR may claim the same issue'; completed skipped = 'Packed-tarball smoke (opt-in)'; in_progress = Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, Lint (CI release and engine gates, ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, scoped query-options erasure, scoped slot lookup and corpus, scoped tooling self-tests), single-writer path, Part-of guard, 'The card this PR closes must claim this branch', Type Check (consumer gates, debt ledger, source gates, workspace), filter. CI convergence is the PM's.","mcp_calls": "0",
"api_writes": "2 seat-side REST writes, both POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay; route resolved 'dispatch'). (1) pr_create, run 38685095865 (success): executed as POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23614/assignees [os-bill]. Read back identical (9743 bytes), assignee listed, draft true. (2) This os-dev-report comment: post-stamped through the relay op comment, POST /repos//issues/23594/comments. Plus git push (not REST): the empty branch, then one content push (fix, pins and changeset together, per the dispatch's push-nothing-before-the-fix rule). Label writes: 0. The dispatch named no labels and skip-changeset does not apply (a patch changeset ships), so the intersection is empty.",
"open_questions": [
{
"question": "For a reader whose title is now withheld, the nine sys_approval_request success messages render an empty quoted title (Approved ""). The action runner's ${result.} grammar substitutes an absent path with '' and has no fallback. The same shape already toasts for any subject with no resolvable title (920bc12's acceptance note), and the toast copy has shipped (plugin-approvals CHANGELOG, 920bc12). Fix it now, or carry it?",
"options": [
"A: carry it (as built). Business need: measured zero producers. No example app declares field-level read denial or masking on a title field (examples at d3ca88e: the only readable:false grant is showcase_project budget/spent/budget_remaining, and zero maskingRule on example objects). Long-term: a known cosmetic residual stays in server-declared copy; the real fix is a grammar or contract decision taken once. AI-error: no new surface and no tolerant consumer. Scope: zero growth. Cost: such a reader's toast reads Approved "" (no leak; the inbox's own inline copy falls back to the formatted id). Effort: none.",
"B: add a server-named, always-present display field to ApprovalRequestRow (the served title, else object label plus record id), and point the nine messages and four locale bundles at it. Business need: speculative, with zero measured producers. Long-term: sane for every reader, but a near-duplicate of record_title in the spec contract. AI-error: two name fields invite authors to read the wrong one. Scope: grows the spec contract and i18n (9 messages x 4 locales). Effort: medium.",
"C: a fallback operator in the action runner's ${result.} grammar (objectui), plus the spec's interpolation description. Business need: general value for every action toast, but none measured here. Long-term: cross-repo grammar growth that publish-time validation must learn. AI-error: a richer grammar is more to get wrong. Scope: two repos. Effort: large."
],
"recommendation": "A. The widened population has no measured producer (business-need axis), and the residual leaks nothing. B and C each grow a contract without pull (startup-scope axis); B also adds a second name field an AI author can pick wrongly (AI-error axis). If a tenant brings FLS or masking on a title field, C is the long-term shape, because it cures every action toast once rather than one object's nine messages."
},
{
"question": "The action-link pages now withhold the title from every bearer, a reader who could read it included, so the card's positive control ('an approver who can read the field still gets it') holds on the inbox row, request detail and toast, but not on the one-tap page. Keep that?",
"options": [
"A: keep it (as built). The page names the record by object label and id, as the approval.requested notification carrying the link already does. Business need: the bearer still sees process, step, object, record id and requester. Long-term: one rule; a surface with no reader to narrow for serves no subject field (the notification body follows the same rule). AI-error: neutral. Scope: none. Cost: a reader who may read the title loses it on that page. Effort: none.",
"B: narrow the page under the token slot's person: resolve their grants outside a request (resolveUserAuthzGrants, as organizationAdministrators does) into a context, and read the request under it. Business need: speculative, no measured complaint. Long-term: a second derivation of a person's envelope beside resolveAuthzContext, which is the per-recipient mechanism the seat declined as option C for the notification (6183426689). AI-error: a reconstructed envelope can silently disagree with the session's (API-key scope, delegation, posture). Scope: grows a mechanism. Effort: medium."
],
"recommendation": "A. It is the dispatch's stated safe answer for a context that cannot be narrowed, and it matches the notification that delivers the link. B builds the per-recipient derivation already declined once (long-term and AI-error axes), with no measured pull (business-need and startup-scope axes)."
}
],
"out_of_scope_findings": [
"carrier: objectui#12308 (the server-authored-text family card) or the seat · noted, not filed: the request-action success messages render an empty quoted title when record_title is absent, and this PR widens that population to readers whose title is withheld (open question 1).",
"carrier: #23556 (its remaining half) · noted, not filed: nameSubjectRecord's inline comment ('withheld until resolveSubjectDisplays narrows it by each reader's FLS') names a condition this PR meets in the inbox. The notification still has no reader to narrow for, so its title must stay withheld until that half supplies a per-recipient reader. Left untouched, as the dispatch ruled.",
"carrier: none (承接者:无) · noted in Acceptance notes only: lifecycle-hooks.ts recordTitleOf names the record's title in the RECORD_LOCKED refusal, taken from the engine's update pre-image. Whether that pre-image is narrowed by the writer's field-level security was not measured (read-only inference).",
"carrier: none (承接者:无) · noted in the PR body only: assumption 4. The subject object's declared API exposure gates neither its snapshot nor its title on the approval path, and the #10749 header says the seam 'does not gate on OBJECT-level access'. Whether exposure should gate the approval path at all is a design question; not built."
],
"line_budget": "478 changed lines (+431 / -47) over 8 files vs the human-merge threshold 3000: under. No skills/** and no governed surface touched.",
"deviations": [
"Push order: AGENTS.md's push-a-WIP-commit-before-minutes rule yielded to the dispatch's security instruction ('Push nothing until the fix sits on the red pins'). The pre-fix red run, the fix and the ablations ran on local commits. The first content push carried fix, pins and changeset together.",
"File surface: four test files beyond the claim's named files, all tests of the claimed sources. payload-display-target-exposure.test.ts and implicit-reference-target-lookup.test.ts call the private enrichRows, which now requires the served map. action-page-member.integration.test.ts's POST parity twins relied on a shared title. approval-requested-notification.integration.test.ts has one comment that this change made false. The PR body names each.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), and the PR body ends with AGENTS.md's session-URL footer, not the harness reminder's model-named trailer and footer. The reminder yields to repository instructions by its own priority sentence."
],
"files_changed": [
".changeset/23594-subject-title-reader-fls.md",
"packages/plugins/plugin-approvals/src/approval-service.ts",
"packages/plugins/plugin-approvals/src/action-link-pages.ts",
"packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts",
"packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts",
"packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts",
"packages/plugins/plugin-approvals/src/implicit-reference-target-lookup.test.ts",
"packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23614 comment 6185083701 ·
domain:servicesseat 1 ·session_017WLmd5in9RXu7AjyHR8fKQ· 2026-10-15T05:23Z. Contract-tier record6185049769: PASS ond3ca88e8d3. The family's remaining two locations are filed as #23618. Landing waits on every check green.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landing note ·
domain:servicesseat 1 ·os-bill·session_017WLmd5in9RXu7AjyHR8fKQ· 2026-10-15T05:46ZFixes #23594)added_to_merge_queue2026-10-15T05:28Z by the fleet relay →merged2026-10-15T05:46Zorigin/mainby content9f81260487.approval-service.tsholdsprivate async servedSubjectFields(1 hit).action-link-pages.tsholds 0 hits of the oldreq.record_title || req.record_idfallback.subject-title-reader-fls.integration.test.tsand.changeset/23594-subject-title-reader-fls.mdare presentState change: the card was closed
completedbyFixes #23594. This write clearspm:dispatchedand the assignee.security,domain:servicesand the type labels stay.Residuals carried (contract record
6185049769, ACCEPT6185083701):Approved ""in the decision toasts. It is carried on console UX quality (parent): five defect classes from the objectui 1fb1ced36243 browser pass — false success, missing write feedback, dead-end flows, dishonest status, labels and disabled reasons — plus three polish items objectui#12308.crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556's unlock is re-derived on that card in this act.Generated by Claude Code
fix(plugin-approvals): name the record by object label and id in ever…