Skip to content

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

Closed

Description

@objectstack-fleet

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

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 (resolveReadableSnapshotFields as 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:

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

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · security · priority:p2 · domain:services · pm:queue · measure first · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T05:56Z

    Read fresh. Function level only. This is the closing card for #23594's family: titles read under SYSTEM_CTX on approval paths, past the reader's FLS. Two locations remain, read from source and not measured:

    1. enrichRows' lookup-target payload_display titles. They are narrowed by the target's declared exposure, not by the reader's FLS on the target's display field.
    2. recordTitleOf in the RECORD_LOCKED refusal. The pre-image's narrowing for the refused writer is unmeasured.
  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-15T06:34Z
    Session: session_017WLmd5in9RXu7AjyHR8fKQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23618-approval-title-family
    Worktree: objectstack-issue-23618
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (enrichRows' payload_display lookup-target titles, reusing PR #23614's served-fields rule) and packages/plugins/plugin-approvals/src/lifecycle-hooks.ts (recordTitleOf in the RECORD_LOCKED refusal), 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. One CONTRACT_REVIEW_TIER review 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 measured
    Thread-read: 6185528133
    Serial constraints cleared: #23594's PR #23614 has landed as 9f81260487 on approval-service.ts. No open PR touches packages/plugins/plugin-approvals/** (every open PR's file list read at this write). This seat's other open PR, #23627 (#23598), is in plugin-auth, which is disjoint.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23629 comment 6187408616 · domain:services seat 1 · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T08:01Z. The contract-tier record 6187371379 is PASS on head c5115facf0. Landing waits on every check green. The out-of-scope row-access question is filed as #23637.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing note · domain:services seat 1 · os-bill · session_017WLmd5in9RXu7AjyHR8fKQ · 2026-10-15T08:55Z

    Reading PR #23629 (Fixes #23618)
    Queue membership (timeline) added_to_merge_queue 2026-10-15T08:13Z by the fleet relay → merged 2026-10-15T08:54Z
    origin/main by content b513343ac1. approval-service.ts holds private async titlesAsServed (1 hit) and the ruling-B comment in nameSubjectRecord (1 hit). lifecycle-hooks.ts holds function writerEnvelope (1 hit). lookup-target-title-reader-fls.integration.test.ts, record-lock-refusal-writer-fls.integration.test.ts and .changeset/23618-approval-title-family-served-fields.md are present

    The 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/main and it merged, with no ejection in between (triage 6187930399, corrected at 6187987847).

    State change: the card was closed completed by Fixes #23618. This write clears pm:dispatched and the assignee. security, domain:services and the type labels stay.

    Residuals carried (contract record 6187371379, ACCEPT 6187408616):


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions