Repository navigation
security(approvals): two more approval-path titles are read under the system context without the reader's field-level security (lookup-target payload_display titles; the RECORD_LOCKED refusal) — #23594's family, measure first #23618
Description
Filing gate ①, the security exception (may leak data). First point: measure reachability first. Both locations below are read from source by the contract review of PR #23614 (6185049769, ③) and are not yet measured. Function level only; ⛔ no reproduction recipe on this card. Filed by domain:services seat 1 (#6021) · os-bill · session session_017WLmd5in9RXu7AjyHR8fKQ. ⛔ Not a claim; grade and lane are triage's.
Family: this is the closing card for the remaining locations of #23594's class. In that class, a record's title is resolved under SYSTEM_CTX and served on an approval path without the reading or acting caller's field-level security. #23594 (PR #23614) fixes the subject's own record_title on the inbox, the request detail, the decision toasts and the action-link pages. This card enumerates what that card's scope left out. One card covers the family; ⛔ no point cards.
Reader who acts: triage grades and routes it. The landing is packages/plugins/plugin-approvals (domain:services).
The governing text
payload-redaction.ts's header ([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): "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): record access stays with object permissions, sharing and the application's row-level rules.
Enumeration (each row is a location the fix's pin must cover, or close with a measurement)
| # | Location | Reader | What the source shows | Status |
|---|---|---|---|---|
| 1 | approval-service.ts enrichRows, the payload_display lookup-target titles |
a routed approver reading the inbox or the request detail | The lookup keys come from the reader-redacted snapshot, so a withheld lookup field lends nothing. A readable lookup field's TARGET is titled from the whole target record under SYSTEM_CTX, narrowed only by the target object's declared exposure (#22738), not by the reader's field-level security on the target's display field. |
read, not measured |
| 2 | lifecycle-hooks.ts recordTitleOf, the RECORD_LOCKED refusal |
a WRITER refused while an approval holds the record | The refusal names the record's title, taken from the engine's update pre-image (ctx.previous). Whether that pre-image is narrowed by the writer's field-level security was not measured. |
read, not measured |
What done looks like
- Row 1: a target title is served only when the reader may read the target's display field, with the same served-fields rule PR fix(plugin-approvals): a request's subject title is served only from fields its reader may read #23614 applies to the subject (
resolveReadableSnapshotFieldsas the reader, on the target object). Pins: a restricted reader gets the target's id rather than its title, a permitted reader still gets the title, and each negative pin is ablated. - Row 2: measure first. If a writer whose field-level security withholds the display field can be told its value by the refusal, the refusal names the record by id (or by what the writer may read). If it cannot, close the row with the measurement.
- ⛔ No grant to anyone. ⛔ No new gate.
Dedupe
MCP search_issues on objectstack-ai/objectstack, closed included:
approvals payload_display lookup target title field-level security reader SYSTEM_CTX referenced record title redaction: 7 hits. 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 (open; the subject title, the positive control) 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; the targets' declared exposure, not reader FLS). 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, finding(security): every write to a controlled_by_parent detail TWO levels below its root master answers 403 "master … not editable" for a principal who edits that master — a regression against 17.7.0, measured on hotcrm's crm_quote_line_item #23093, plugin-approvals: sys_approval_request's 8 actionvisiblepredicates readrecord.viewer, a block the service attaches on read, and the shared expression validator refuses all 8 as an undeclared field #22211, spec(ui):record:approval_decisionon any page other thansys_approval_request's parses and validates clean, then draws nothing (an authoring trap: placement is declared in prose, not enforced) #22538 and ADR-0079 title: 19 objects that declaretitleFormatand NO pointer getnameField: idstamped by the registry designation pass, so a renderer honouring the declared pointer (objectui#9436) shows the raw record id as the title #20044 are closed and different.RECORD_LOCKED refusal names record title approval lock writer field-level security recordTitleOf: 14 hits. 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 came back as the positive control. security(storage): the attachment gate's delete and update refusals name the parent record to a caller outside the floor's domain who cannot read it #21755 (closed; the attachment gate's refusals naming a parent record) is the nearest sibling, in another package. None covers either row.
Dedupe words: payload_display target title FLS; lookup target title SYSTEM_CTX; RECORD_LOCKED title writer FLS; recordTitleOf pre-image.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 15, 2026
Triage: first grade ·
bug·security·priority:p2·domain:services·pm:queue· measure first · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T05:56ZRead fresh. Function level only. This is the closing card for #23594's family: titles read under
SYSTEM_CTXon approval paths, past the reader's FLS. Two locations remain, read from source and not measured:enrichRows' lookup-targetpayload_displaytitles. They are narrowed by the target's declared exposure, not by the reader's FLS on the target's display field.recordTitleOfin theRECORD_LOCKEDrefusal. The pre-image's narrowing for the refused writer is unmeasured.priority:p2+security: the same class and grade as 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, a title-level disclosure to a caller already on the approval path. If a row measures a whole-record or cross-principal leak, the dev stops and the card returns here.domain:services(plugin-approvals). Reuse the served-fields rule PR fix(plugin-approvals): a request's subject title is served only from fields its reader may read #23614 applied, at one place. ⛔ No second derivation.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 2 · 2026-10-15T06:34Z
Session:
session_017WLmd5in9RXu7AjyHR8fKQAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23618-approval-title-familyWorktree:
objectstack-issue-23618Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface:
packages/plugins/plugin-approvals/src/approval-service.ts(enrichRows'payload_displaylookup-target titles, reusing PR #23614's served-fields rule) andpackages/plugins/plugin-approvals/src/lifecycle-hooks.ts(recordTitleOfin theRECORD_LOCKEDrefusal), with their tests; one changeset.changeset/23618-*.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"). Measure first, then narrow a served field. OneCONTRACT_REVIEW_TIERreview is owed before the queue, as for #23594.Clause-②: no
Responsibility:
plugin-approvals (this lane): enrichRows titles lookup targets under SYSTEM_CTX narrowed by the target's exposure only, and recordTitleOf names the record from the update pre-image | PR #23614 (#23594, 9f81260487) already narrows the subject title by the reader's served fields | a routed approver reading the inbox or the request detail (row 1), and a writer refused while an approval holds the record (row 2); both read from source, not measuredThread-read: 6185528133
Serial constraints cleared: #23594's PR #23614 has landed as
9f81260487onapproval-service.ts. No open PR touchespackages/plugins/plugin-approvals/**(every open PR's file list read at this write). This seat's other open PR, #23627 (#23598), is inplugin-auth, which is disjoint.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{
"issue": 23618,
"status": "done",
"branch": "claude/issue-23618-approval-title-family",
"pr": "#23629",
"session": "session_017WLmd5in9RXu7AjyHR8fKQ - the parent's id (mode:subagent); the claim's Session line, the relay envelope's session and the commits' Claude-Session trailer agree",
"premise_still_valid": true,
"summary": "Both rows measured as leaking, title-level only, with no whole-record or cross-principal leak, and both are fixed with the served-fields rule PR #23614 applied, asked through one seam. Row 1: enrichRows titled each payload_display lookup target from the whole target under SYSTEM_CTX, gated only by the target's exposure. ApprovalService.servedFields is now the one place a row read asks resolveReadableSnapshotFields as the reader, once per object per read; a target that is also a subject object reuses the subject's answer. titlesAsServed is the one title pick (redactSnapshot(record, served), then pickTitle) shared by record_title and payload_display, so a withheld or masked target display field gives that key no display value and the stored id stands. Row 2: the RECORD_LOCKED pre-image is the raw stored row. Measured with the real SecurityPlugin in a throwaway rig (deleted, never committed): on main's wiring, a writer whose permission set withholds the display field was told its value in the refusal. The sentence now picks its title from the pre-image as served to the writer, asked with the writer's own envelope (input.options.context minus its operation-private keys, never the lossy hook session). It degrades to the label-only sentence, never the id; RECORD_LOCKED/409 are unchanged, and a permitted writer still gets the title. Per the coordinator's mid-task order (triage ruling 6185594767), nameSubjectRecord's inline comment was reworded; the return value is unchanged. One patch changeset. PR #23629 is draft, assignee os-bill. The worktree and its node_modules were removed after the final push.",
"tests": "Red before, the two new pin files on the base's source (commit c0c5a0b): 8 failed / 2 passed; only the two controls (b) passed. Package suite, pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 77 files / 1072 tests passed at d3a9c5f, and again at the final head c5115fa (after merging origin/main 7232994, a frozen install and a rebuild of the package and its closure, 32 tasks, exit 0); lock VERDICT command-exit 0. pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 at both heads; check:test-typecheck held at 8 files / 324 errors / 27 signatures with no new signature, and tsc --listFiles on tsconfig.test.json compiles all 4 new or edited test files. Ablations at d3a9c5f, via scripts/ablation-replace.mjs in wrap mode under an EXIT/INT/TERM restore trap, each run over the 2 new files plus subject-title-reader-fls (18 tests), each with a predicted red set: 1a, the targets' served answer replaced by undefined: 3 failed / 15 passed = row 1 (a), (a-masked), (c), as predicted. 1b, the targets' ask deleted: 4 failed / 14 passed = row 1 (a), (a-masked), (c), (d), as predicted. 2, the refusal titled from the raw pre-image: 3 failed / 15 passed = row 2 (a) by-id, (a) predicate, (a-masked), as predicted. Every restore proven by blob equal to HEAD (0a8b5fc6121d, 0a8b5fc6121d, 25e7a598c358) and an empty git diff HEAD. No dist step: the subjects are imported from src by relative path.",
"gates": "Local, at c5115fa: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 67 commands off this change set (the PM's dispatch-time lead had 50; the extra 17 are the changeset, engine-double and release self-test families this diff reaches). All 67 ran sequentially, each exit captured before any pipe: 67 exit 0. check:dual-build-cjs-loads and check:i18n first answered 3, PREREQUISITE NOT MET (no workspace dist); after a workspace build (75 tasks, exit 0) both answered 0, recorded at that measured 0. --ran reconciliation with every exit code recorded: 67 derived, 67 run, 0 NOT-MEASURED (a derived zero), 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 are not owed: no packages/spec build input or frame file is touched. PR #23629 check-runs on c5115fa at one read, no waiting: 37 total. 7 success: Check Documentation Links, Check PR Size, both claim guards, the single-writer guard, Part-of guard, filter. 6 skipped, the expected-skip set: Build Docs, Console Pin Gate, Dogfood Regression Gate matrix shard, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. 24 in_progress, honest value: Build Core, Check Changeset, Governed Surface Queue Guard, the 8 Lint families, Test Core 1/6 to 6/6, the 4 Type Check families, Auto Label, Dogfood Regression Gate, Flag docs affected by code changes. None failed at the read.",
"mcp_calls": "0 - no MCP GitHub call of any kind; every read went through gh api (single-card and PR REST reads)",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]. (1) pr_create, run 38700649674: POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos//issues/23629/assignees [os-bill]; read-back identical, 12327 bytes sent and stored. (2) this os-dev-report comment: POST /repos//issues/23618/comments. Label step: zero writes, because the dispatch named no label and skip-changeset does not apply (a changeset ships). Not REST: 3 git pushes of the branch (the empty probe, the content push, the merge).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (no carrier; PR #23629 Acceptance notes only) - noted, not filed: a payload_display lookup target is still read under the system context, so its title is narrowed by its declared exposure (#22738, which takes no caller) and now by the reader's field-level security, but not by the reader's row-level or sharing access to the target record. That is a design question beside #7497 ruling C, read and not measured, not a defect this card names. Dedupe words: payload_display target row-level access; referenced record title sharing; lookup target title RLS."
],
"deviations": [
"Push order: the pins, the fix and the changeset were committed locally and went up in one push, after the empty-branch probe, so nothing red reached the remote before the fix. That is the card's security rule; AGENTS.md otherwise asks for a WIP push before long steps.",
"File surface beyond the claim's two sources and their tests: packages/plugins/plugin-approvals/src/approvals-plugin.ts, one wiring line handing the lock hook the lazily resolved security authority the redaction already reads; and the two pass-through tests (payload-display-target-exposure, implicit-reference-target-lookup), which pass enrichRows' new required context. All are named in the PR body.",
"Bounded in-place comment change, ordered mid-task by the coordinator under triage ruling 6185594767 (#23556, option B): nameSubjectRecord's inline comment, two lines, with no behaviour change. It is declared in the PR body and has no test or changeset line of its own.",
"Attribution: commits carry the repository's model-free trailer pair, and the PR body ends with the repository's session-URL footer, rather than the harness reminder's model-named trailer and footer (AGENTS.md wins; the pre-push hook refuses a model id).",
"Throwaway measurement: one test file in packages/plugins/plugin-security/src was run once with the real SecurityPlugin composed and deleted; the tree was verified clean and nothing from it was committed or pushed.",
"Merged origin/main (7232994, which included a breaking metadata-core/metadata-protocol/driver-sql change consumed by this package's tests) before opening the PR, per AGENTS.md section 10; clean merge, no regen debt, and suite plus typecheck were rerun on the merged head."
],
"line_budget": "not applicable: no skills/** or ledgered line-budget surface touched; no governed surface. The diff is +638/-45 over 8 files, under the 3000-line human-merge threshold.",
"files_changed": [
".changeset/23618-approval-title-family-served-fields.md",
"packages/plugins/plugin-approvals/src/approval-service.ts",
"packages/plugins/plugin-approvals/src/approvals-plugin.ts",
"packages/plugins/plugin-approvals/src/lifecycle-hooks.ts",
"packages/plugins/plugin-approvals/src/lookup-target-title-reader-fls.integration.test.ts",
"packages/plugins/plugin-approvals/src/record-lock-refusal-writer-fls.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 #23629 comment 6187408616 ·
domain:servicesseat 1 ·session_017WLmd5in9RXu7AjyHR8fKQ· 2026-10-15T08:01Z. The contract-tier record6187371379is PASS on headc5115facf0. Landing waits on every check green. The out-of-scope row-access question is filed as #23637.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landing note ·
domain:servicesseat 1 ·os-bill·session_017WLmd5in9RXu7AjyHR8fKQ· 2026-10-15T08:55ZFixes #23618)added_to_merge_queue2026-10-15T08:13Z by the fleet relay →merged2026-10-15T08:54Zorigin/mainby contentb513343ac1.approval-service.tsholdsprivate async titlesAsServed(1 hit) and the ruling-B comment innameSubjectRecord(1 hit).lifecycle-hooks.tsholdsfunction writerEnvelope(1 hit).lookup-target-title-reader-fls.integration.test.ts,record-lock-refusal-writer-fls.integration.test.tsand.changeset/23618-approval-title-family-served-fields.mdare presentThe queue path. The first merge group was built on #23504's queue commit and went red on #23504's own test (anchor #23641). The queue rebuilt this PR's group on
origin/mainand it merged, with no ejection in between (triage6187930399, corrected at6187987847).State change: the card was closed
completedbyFixes #23618. This write clearspm:dispatchedand the assignee.security,domain:servicesand the type labels stay.Residuals carried (contract record
6187371379, ACCEPT6187408616):crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556's notification stays label-and-id by ruling B.Generated by Claude Code