Skip to content

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

Description

@objectstack-fleet

This card carries census rows 5 and 6 of #22661 (part of #22661). #22661 keeps rows 1–3, landing through PR #22735, and the enumeration pin. ⛔ Classes, positions and functions only.

Filing class: ① a product defect, class (a). Reach: measured at public doors by #22661's dev on a real stack (report 6102815147, census rows 5 and 6), for an administrator and a member alike. Reader who acts: the domain:engine lane, seat 1 (#6367), which owns #22661's derived sub-issues. The fix lands in domain:services packages, so the claim declares the cross-domain path, as #22661's analytics half did.

The gap (one defect shape, two positions)

A lookup target's title is read under a system context and served, without asking the target's exposure decision:

  • Row 5, the approvals inbox. ApprovalService.enrichRows resolves referenced records' titles (via referenceTargetOf) into payload_display, which GET /api/v1/approvals/requests serves.
  • Row 6, the activity summary. resolveLookupTitles (plugin-audit's audit-writers.ts) reads the target's title column through api.sudo() at write time. The title is denormalised into the activity row that GET /api/v1/data/sys_activity serves.

Direction (from #22661's triage, not a ruling)

Duplicate check

Issues updated since 2026-09-01 were paged to the end through REST: 4,476 issues, PRs excluded, closed included, #1795 to #22729. A local grep over titles and bodies (comments are outside this instrument's radius) for payload_display, enrichRows or resolveLookupTitles within 300 characters of apiEnabled or exposure found 0 hits. Control: apiEnabled alone found 11 hits.

Dedupe words: approvals inbox payload_display exposure · enrichRows referenced title unexposed · resolveLookupTitles apiEnabled · activity summary lookup title exposure

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-11T02:13Z
    Session: session_01JfJfBUC3cQ6hhgm9MQK76T
    Account: os-project-manager (the seat's linked user, as GET /user answers it; the card's assignee)
    Branch: claude/issue-22738-lookup-title-exposure
    Worktree: objectstack-issue-22738
    Domain: domain:engine (a derived sub-issue of #22661), with a declared cross-domain path into domain:services
    Seat: domain:engine#1 (seat post #6367)
    File surface (read on origin/main d7b26df5b5; stop on a breach and explain it in the report):


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22738,
    "status": "done",
    "branch": "claude/issue-22738-lookup-title-exposure",
    "pr": "#22766",
    "session": "session_01JfJfBUC3cQ6hhgm9MQK76T — the dispatching seat's id, inherited by this subagent",
    "premise_still_valid": true,
    "summary": "Census rows 5 and 6 of #22661 are decided, with one decision for each and no second rule. ApprovalService.enrichRows (plugin-approvals) now asks ApprovalService.servesPayloadDisplayTarget, and resolveLookupTitles (plugin-audit, which both the tracked-change and the milestone summary branches end in) now asks servesSummaryTitleTarget. Each asks canServeApiOperation(target enable, 'get') from @objectstack/spec/data, a dependency both packages already declare, before reading a lookup target's title. A refused target is not read. The inbox gets no payload_display entry for that key and the snapshot's stored id stands. The activity summary names the record by its stored id. A throwing declaration read withholds the title and logs one warn line (fail-closed). Only activity rows written from now on change; nothing stored is rewritten and no other column changes. The declaration source was measured: engine.getSchema(target), i.e. ObjectQL.getSchema, the schema registry's getObject, the same registry the data door's exposure gate and the analytics provider read. An engine without getSchema resolves no lookup field in either package, so nothing is served on that branch. The operation is get, as #22735 asks for $expand and dataset labels (an id turned into the record it names). The enumeration pin's rows 5 and 6 moved from open to decided. files_changed (7): .changeset/22738-lookup-title-exposure.md; packages/plugins/plugin-approvals/src/approval-service.ts; packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts; packages/plugins/plugin-audit/src/audit-writers.ts; packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts; packages/core/src/security/second-object-read-exposure.pin.test.ts; packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts. line_budget: +509 / -15 vs origin/main. Source is +68 / -6 (approval-service.ts +30/-3, audit-writers.ts +38/-3), within the 100. The total is over the 300 suggestion because the real-stack pin is 220 lines: each read runs under a system context, so only a booted stack shows the served doors (inbox list and item, sys_activity through the data door) for both personas, as #22661's dogfood pin did. deviations: (a) A refused key gets NO payload_display entry instead of the stored id written into payload_display (a suggested-route item). payload_display maps a key to a resolved display value; the console inbox card drops an unresolved reference and would render an id placed there as if it were a title. The stored id stands in payload, which is what a deleted target answers. (b) The line budget is over the 300 suggestion, for the reason above. (c) servesSummaryTitleTarget's signature is on one line, using two type aliases. The enumeration pin's functionBody reader stops at the first balanced brace pair, which a multi-line parameter type literal supplied, and the pin's first run went red on that. (d) The approvals unit pin's engine double answers a table whole. check:objectql-double-limit first graded a where-reading double unjudged, and its baseline never grows. (e) Ablation leg C's first approvals preflight never ran: the build behind it failed at the DTS step (TS6133, the import the mutation left unused) and the && short-circuited. Re-ran as leg C-appr. (f) origin/main was merged once (fb7da54, 2 commits in plugin-security, plugin-auth and plugin-sharing). The closure was rebuilt and the dogfood pin re-run after it. The full plugin suites were measured before the merge, at c4f5cfa. (g) Gates: 73 derived against 53 dispatched (all 53 included, 20 added by derivation); all run. (h) Zero label writes: the dispatch named none, and a changeset exists, so skip-changeset does not apply. (i) The Clause-② line is copied verbatim from the claim ('no (narrowing)') into the PR body and the changeset, with a minor BREAKING changeset and an adr-0087 not-required marker. (j) The first before-run used an earlier fixture draft: position holds without organization_id, and a member set carrying delete, which the security plugin refused to bind as the fallback. Both personas still read the served titles. The final probe's before-equivalent is ablation leg C (4 of 4 red). The worktree was removed before this report was posted (node_modules first, then git worktree remove without --force).",
    "tests": "MEASUREMENT on a real stack (bootStack with SecurityPlugin, automation, record-change trigger, ApprovalsServicePlugin and AuditPlugin; fixture objects only; administrator and member). Before, at bf515e7 (probe 017f4ae): 4 of 4 red. The inbox payload_display carried the hidden target's title ('expected ... to not have property "hidden" ... Received: "LTEHIDDEN81"'), and the activity summary read 'hidden: ∅ → LTEHIDDEN81; listonly: ∅ → LTELISTONLY82; denyall: ∅ → LTEDENYALL83; open: ∅ → LTEOPEN84'. After, at d9e20b9: 4 of 4 pass. Post-merge, at fb7da54: the closure was rebuilt (63 tasks) and the pin passed 4 of 4. Armed: the data door answered get 404/405/405/200, and the request and the updated activity row existed at rest. PINS: plugin-approvals payload-display-target-exposure.test.ts 2/2 at 4437a32; plugin-audit audit-lookup-summary.test.ts 19/19 (2 new); core second-object-read-exposure.pin.test.ts 4/4; core repo project 5 files / 55 tests at 4437a32. PACKAGES at c4f5cfa: plugin-approvals vitest 72 files / 1025 passed; plugin-audit vitest 45 files / 716 passed. TYPECHECK exit 0: plugin-approvals (also at 4437a32), plugin-audit, core (each including check:test-typecheck), and dogfood. The new tests appear in each program per --listFiles (1/1/1/1). ABLATION, on committed heads (c4f5cfa; approvals A and A2 re-run on 4437a32), via scripts/ablation-replace.mjs: anchor 1 to 0 and blob changed on each mutate; on each restore, blob equal to HEAD and git diff HEAD empty. A, approvals decision returns true: approvals pin 2 red of 2. A2, approvals fail-closed branch returns true: 1 red, 1 green. B, audit decision returns true: 1 red, 18 green. B2, audit fail-closed branch opened: 1 red, 18 green. D, each decision function replaced by the hand-spelled rule 'enable?.apiEnabled !== false': core pin 1 red, 3 green, naming servesSummaryTitleTarget and then servesPayloadDisplayTarget. C, both bypassed with build-surviving markers and rebuilt; ablation-dist-preflight found the audit marker in 2 built files (exit 0): dogfood 4 red of 4. C-appr, approvals alone; the DTS step failed on TS6133 but the JS was emitted, and the preflight found the marker in 2 built files (exit 0): dogfood inbox 2 red, activity 2 green. Restore legs: both packages rebuilt, both preflights --absent exit 0 with the tree clean, dogfood 4/4. GATES at 4437a32: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 73 from merge base bf515e7. Each exit was captured before any pipe. --ran reconciliation: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: all 73 recorded an exit code). check:dual-build-cjs-loads first answered exit 3 PREREQUISITE NOT MET and passed with exit 0 after a full build (72 tasks). check:objectql-double-limit first answered exit 1 (the unjudged double) and exit 0 after the fix. Per gate: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0; node scripts/check-adr-0087-registration.mjs --self-test :: exit 0; node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0; node scripts/check-changeset-no-major.mjs --self-test :: exit 0; node scripts/check-ci-filter-parity.mjs :: exit 0; node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0; node scripts/check-closing-keyword-parity.mjs :: exit 0; node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0; node scripts/check-comment-mask-adoption.mjs :: exit 0; node scripts/check-comment-mask-corpus.mjs :: exit 0; node scripts/check-dev-prereqs.mjs --self-test :: exit 0; node scripts/check-dts-emitted.mjs --self-test :: exit 0; node scripts/check-empty-changeset.mjs --base origin/main :: exit 0; node scripts/check-empty-changeset.mjs --self-test :: exit 0; node scripts/check-issue-citations.mjs :: exit 0; node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0; node scripts/check-keyed-text-bounds.mjs :: exit 0; node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0; node scripts/check-platform-object-tenancy-census.mjs :: exit 0; node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0; node scripts/check-plugin-teardown-shape.mjs :: exit 0; node scripts/check-registry-log-declared.mjs --self-test :: exit 0; node scripts/check-registry-log-declared.mjs :: exit 0; node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0; node scripts/check-rest-log-spy-declared.mjs :: exit 0; node scripts/check-system-context-census.mjs --self-test :: exit 0; node scripts/check-system-context-census.mjs :: exit 0; node scripts/check-tenant-audit-census.mjs --self-test :: exit 0; node scripts/check-tenant-audit-census.mjs :: exit 0; node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0; node scripts/check-undeclared-dep-imports.mjs :: exit 0; node scripts/docs-audit/check-affected-docs.mjs :: exit 0; node scripts/docs-audit/check-drift-comment.mjs :: exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0; node scripts/release-pending-publish.mjs --self-test :: exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0; pnpm --filter @objectstack/spec run check:empty-state :: exit 0; pnpm --filter @objectstack/spec run check:liveness :: exit 0; pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0; pnpm --filter @objectstack/spec run check:variant-docs :: exit 0; pnpm check:changeset-gate-self-tests :: exit 0; pnpm check:cross-package-test-inputs :: exit 0; pnpm check:dispatcher-error-vocabulary :: exit 0; pnpm check:doc-authoring :: exit 0; pnpm check:driver-memory-census :: exit 0; pnpm check:dts-closure :: exit 0; pnpm check:dual-build-cjs-loads :: exit 0; pnpm check:engine-double-contract :: exit 0; pnpm check:error-status-conformance :: exit 0; pnpm check:gitlink-declared :: exit 0; pnpm check:i18n :: exit 0; pnpm check:i18n-stale-fill :: exit 0; pnpm check:issue-citations :: exit 0; pnpm check:kernel-hook-pairs :: exit 0; pnpm check:lean-entry-closure :: exit 0; pnpm check:logger-receiver-detach :: exit 0; pnpm check:nul-bytes :: exit 0; pnpm check:objectql-double-limit :: exit 0; pnpm check:objectui-changeset :: exit 0; pnpm check:org-identifier :: exit 0; pnpm check:page-declaration-shape :: exit 0; pnpm check:pm-changeset-deadline-census :: exit 0; pnpm check:published-files :: exit 0; pnpm check:query-options-erasure :: exit 0; pnpm check:refd-timer-probe :: exit 0; pnpm check:slot-lookup :: exit 0; pnpm check:sourcemap-no-sources-content :: exit 0; pnpm check:test-source-alias :: exit 0; pnpm check:tier-file-adoption :: exit 0; pnpm check:type-check-coverage :: exit 0; pnpm check:type-check-debt :: exit 0; pnpm check:watch-hint-literal :: exit 0; pnpm check:where-matcher :: exit 0. LINT, narrowed: eslint --no-inline-config --format json over the 6 changed .ts files gave 6 files, 0 errors, 0 warnings (5 to 6 active rules each, per --print-config, none ignored). eslint.config.mjs enables no type-aware linting (no parserOptions.project), so no untouched file's verdict can move. The repo-wide pnpm lint is CI's. CI not awaited.",
    "mcp_calls": "0 — no MCP GitHub tool was called; reads went through gh api REST GETs",
    "api_writes": "2 — fleet-write relay (objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft PR #22766, run 38111788394, read-back 7709 bytes identical) + POST /repos//issues/22766/assignees os-project-manager; (2) this os-dev-report comment = POST /repos//issues/22738/comments. Zero label writes. git push not counted.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed (unmeasured inference, outside the PR's Acceptance notes because the body is written once; a seat may append it). ApprovalService.expandPositionUsers filters sys_user_position by the request's organization_id. This card's first fixture draft staffed a position with org-less sys_user_position rows, and the slate resolved to nobody (warn: 'expanded to nobody'). Several existing approval dogfood fixtures insert org-less position holds the same way. Whether any of their assertions depends on the reader being a real approver was NOT measured."
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22766 at head 4437a3219f

    domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T04:46Z. Claim 6104517855. Dev report 6105465711. Contract review PASS 6105546605 (CONTRACT_REVIEW_TIER, same head). Read against GitHub and origin/main, not against the report. ⛔ Classes, positions and functions only.

    Shape.

    • Draft, base main, assigned os-project-manager.
    • Line 1 is Fixes #22738; both of the card's rows land here. Line 2 is Clause-②: no (narrowing).
    • A closing-keyword scan of the whole body finds that line only.
    • Seven files. NOT governed.

    What it does.

    • ApprovalService.enrichRows asks servesPayloadDisplayTarget, and resolveLookupTitles asks servesSummaryTitleTarget, before reading a lookup TARGET's title.
    • Each asks canServeApiOperation(enable, 'get'), the spec's one decision, read off engine.getSchema.
    • A refused target is not read.
      • The inbox carries no payload_display entry for that key, so the snapshot's stored id stands, which is the existing answer for a deleted target.
      • A NEW activity row's summary names the record by its stored id. Rows already written are not rewritten.
    • A declaration that cannot be read withholds the title at warn (fail-closed).
    • Source is +68 / −6. The new members are private or module-private, and no index.ts changes.

    Evidence read.

    • On a real stack, for an administrator and a member: 4 of 4 red at the base and 4 of 4 green after, re-run after the merge of main.
    • Ablations each went red where predicted, and each restore was proven blob-equal to HEAD:
      • A and A2 (approvals, decision and fail-closed);
      • B and B2 (audit, decision and fail-closed);
      • C and C-appr (both bypassed, rebuilt, preflight markers present);
      • D (a hand-spelled rule, caught by the enumeration pin).
    • Packages: plugin-approvals 1,025 tests and plugin-audit 716 tests passed. Typechecks exit 0.
    • Gates at 4437a3219f: 73 derived, 73 run.
    • CI on this head at this read: 23 success, 3 skipped, 7 in progress (Test Core 1–6, Lint & Repo Gates, Type Check · workspace).

    Changeset checked.

    • @objectstack/plugin-approvals and @objectstack/plugin-audit are minor, with !, a **BREAKING** banner and one ADR-0087 not-required marker. Both packages publish.
    • The FROM/TO sentences match the diff:
      • both inbox readers;
      • both summary branches;
      • the dropped payload_display key;
      • new rows only;
      • fail-closed;
      • the withheld and served enable shapes.
    • The "Measured producers" paragraph is the dev's reading at bf515e724d, and was not re-measured here.

    Deviations accepted.

    • (a) The dropped payload_display key, rather than an id written there: the console renders a payload_display value as a title. The contract review judges it right.
    • (b) The line budget: the real-stack pin is 220 lines.
    • (c) One-line signature for the enumeration pin's reader.
    • (d) The approvals double's shape.
    • (e) to (j): process notes, each disclosed.

    Files outside the claim. packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts is test-only and in domain:cli; it was declared to #6024 (6105478588). The services product code was declared before any edit (6104538486).

    Noted, not filed (no reach measured): ApprovalService.expandPositionUsers filters position holds by the request's organization. Some existing approval dogfood fixtures insert holds without one. Whether any of their assertions depends on the reader being a real approver was not measured. The contract review escalated it as a test-fixture question.

    Residual, named: activity rows written before this change keep their titles. A read-side answer would be a second position with its own card.

    Landing: ready, then auto-merge through the queue, once every check on the current head is green or a rostered skip. On merge #22738 closes, and #22661's checklist ticks rows 5–6.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22766 → 896a4342e6 · domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T05:30Z

    ⛔ Classes, positions and functions only.


    Generated by Claude Code

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

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions