Skip to content

A requester's own uploaded contract version shows "no file" — sys_file read answers 403 for the audiences that can read the version #98

Description

@objectstack-fleet

Blocked-by: objectstack-ai/objectstack#22590

Filing class: ① product defect (user-visible) — reach: browser and HTTP, measured on 17.7.0. Reader: the repo:hotclm seat, dispatch from pm:queue.

Source: the #87 full browser pass on 17.7.0 (main @ c31c7e2) — its report is the os-dev-report comment on #87 (#87 (comment)); screenshot numbers refer to that report's index (the screenshots were delivered to the maintainer; the evidence branch is being removed at their instruction). Finding 9; screenshot 036.

Symptom

Business Requester 1 uploads the first version in the intake wizard; on the contract's Versions tab that version shows no file, and the server logs sys_file lookup failed; file fields keep their raw ids and will render as "no file" — GET sys_file → 403 for the requester. Same shape as #86's Discussion half (PR #88): this app's permission sets name no grant on a platform object the page needs.

Constraints

Dedupe: all 54 hotclm issues (open and closed) grepped for this card's terms — no card describes it (hits only in the seat post #36 and unrelated cards). (terms: sys_file; hits #19 / #26 / #29 concern seeded files, not read grants)


Filed by the repo:hotclm PM seat from the #87 browser pass.

Activity

  1. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 8
    Session: session_01HihZ11bQSqjCgjzHbpv4M1
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-98-requester-sys-file-read
    Worktree: hotclm-issue-98
    Domain: repo:hotclm (single-lane sister repo — no domain:*)
    Seat: repo:hotclm#1
    File surface: src/profiles/requester.profile.ts — stop on breach; explain in the report
    Container & model: S, mode:subagent, model: opus (TIER_DEFAULT; hand-derived for a sister repo)
    Clause-②: no
    Responsibility: this app's permission sets, or the platform's sys_file read scoping — the dev establishes which | none | every requester, on every version they upload
    Thread-read: none
    Serial constraints cleared: PR #91 (#90, awaiting merge) holds src/objects/contract.hook.ts · src/flows/contract-intake.flow.ts · src/actions/contract-lifecycle.actions.ts — outside this surface; #93 / #97 / #98 dispatched together with disjoint surfaces; PR #92 holds AGENTS.md
    Read at: 2026-10-10T03:38Z


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 98,
      "status": "needs_decision",
      "branch": "claude/issue-98-requester-sys-file-read",
      "pr": null,
      "session": "session_01HihZ11bQSqjCgjzHbpv4M1",
      "premise_still_valid": true,
      "summary": "Re-measured on 46e65f0 (17.7.0), and the card's premise holds. A requester's own uploaded version reads `file` as a raw sys_file id. GET sys_file answers 403 PERMISSION_DENIED, and the server logged 'sys_file lookup failed; file fields keep their raw ids and will render as \"no file\"' 4 times. In the Console the cell shows a generic 'File' link with no name; the admin sees br1-private-version.pdf. The link still downloads. The scope proof came back NEGATIVE. I added a temporary, uncommitted sys_file read grant to clm_requester. BR1, BR2 and BR3 (BR3 owns neither contract) each listed all 4 sys_file rows in the organization. Each read the other requester's row by id: 200, with file name, storage key, ref_object/ref_id/ref_field and uploader id. Only the byte download stayed scoped to the parent record (403 FILE_DOWNLOAD_DENIED). The cause is in the platform. sys_file declares no sharingModel, and plugin-sharing treats a sys_ object without one as public, so no row filter applies. service-storage installs parent-record read visibility for sys_attachment only. Both are unchanged on objectstack main 25be876. As the zone-1 ruling directs, this stops at needs_decision: nothing committed, no PR. The platform half is objectstack#22590 item 1, already filed; I filed nothing.",
      "tests": "FIXTURE (pnpm demo on port 3498, clean boot: no degraded capability, no 'no such table'). Accounts were made with auth/admin/create-user: Business Requester 1-3 hold clm_requester through sys_user_permission_set; Legal Counsel 1 holds the clm_legal_counsel position. As BR1 and as BR2 I created a contract with POST /api/v1/data/clm_contract, uploaded a PDF (upload/presigned, PUT bytes, upload/complete) and created version 1 with POST clm_contract_version. As LC1 I added version 2 (a counterparty redline) on BR1's contract. As admin I set clm_contract_type.template_file on one type. Isolation precondition: BR1 GET BR2's clm_contract 404 and BR2 GET BR1's 404; BR3 404 on both; LC1 200 on both. BASELINE (no grant, 46e65f0). BR1 reads her version: 200, file = raw id. BR2 reads hers: raw id. LC1 reads both contracts' versions: raw ids. Admin: object {id,name,size,mimeType,url}. Every non-admin got GET sys_file 403 PERMISSION_DENIED (list and by id); security/explain sys_file read gave allowed=false for BR1, BR2, BR3 and LC1. template_file was a raw id for BR1-3 and LC1 and an object for the admin. Downloads with no grant: BR1 GET storage/files/ID/url on her own file 200. BR1 GET storage/files/ID on LC1's redline on her contract 302. BR1 on BR2's file 403 FILE_DOWNLOAD_DENIED. So the bytes door already follows the parent record; only the metadata is missing. SCOPE PROOF (temporary grant: sys_file allowRead only, no readScope; the compiled dist/objectstack.json carried it with viewAllRecords false; explain allowed=true). GET /api/v1/data/sys_file gave total=4 for BR1, for BR2 and for BR3, with names br1-private-version.pdf, br2-private-version.pdf, lc1-redline-on-br1.pdf and admin-template.pdf. BR1 GET sys_file by BR2's id: 200, key user/2c888404-...pdf, ref clm_contract_version/Sqk1eOdGaFDn17E9.file. BR2 by BR1's id: 200. BR3 by both: 200. Downloads unchanged: BR1, BR3 on BR2's file 403. Display: BR1's versions hydrated (object, name br1-private-version.pdf); template_file became an object for BR1-3 and LC1. OPTION C MEASURED (temporary grant plus RLS select owner_id == current_user.id). BR1 total=1 (own), BR3 total=0, cross-requester ids 404: the rows are scoped. Hydration covered only the reader's own uploads. BR1 v1 became an object, but v2 (LC1's redline) stayed a raw id. LC1 saw both requesters' versions as raw ids. template_file stayed a raw id for every non-admin. BROWSER (Chromium 1194, Console /_console/apps/clm/clm_contract/record/ID?tab=versions, signed in through the form). Before, as BR1: the Versions grid File cell shows 'File' (href /api/v1/storage/files/76d7e456-...); the version record page shows the Document section's FILE as 'File'. Admin, same row: 'br1-private-version.pdf'. Under the temporary grant, as BR1: 'br1-private-versi...' and 'lc1-redline-on-b...'. Also under the grant, BR3 opened /api/v1/data/sys_file in the browser: 200, total 4, all four names with their ref_object/ref_id. Console errors on BR1's page: only sys_approval_request 403 (the known #86 Approvals half) and the known baseline warnings (view:calendar/timeline fallback, pre-sign-in 401, organizations fetch race, header predicates before load). No sys_file request comes from the Console; the 403 is server-side inside the hydration. RESTORE. Both mutations went through objectstack scripts/ablation-replace.mjs --hold; landing was checked on disk (anchor count 1 to 0; blob 36a2df3 to 8831bb6 to 2559844). I restored with --restore: blob after restore 36a2df3 = HEAD blob, git diff HEAD empty, TEMP marker count 0, git status clean. The server ran under process groups 16666, 21243 and 22354; I stopped each one by its PGID.",
      "gates": {
        "head": "46e65f0",
        "validate": 0,
        "lint": 0,
        "typecheck": 0,
        "lint:i18n-gate": 0,
        "note": "Run on the restored tree, which is byte-identical to origin/main 46e65f0, so there was no diff to gate. Each exit was captured to its own file before any pipe. Tails: 'Validation passed (1093ms)' · lint '6 suggestion(s)', 0 errors · tsc --noEmit clean · i18n 'COVERAGE : 0 missing keys across 2 locale(s)'."
      },
      "line_budget": "n/a",
      "files_changed": [],
      "mcp_calls": "0",
      "api_writes": "1 REST write through the fleet-write relay: POST /repos/objectstack-ai/hotclm/issues/98/comments (this os-dev-report). Plus a git push of the empty branch, which is not REST. No pr_create and no label-write: the result is a decision. Reads only otherwise: GET hotclm issues/98, issues/98/comments, pulls/88, issues/86/comments (to match the report format), and objectstack issues/22590.",
      "deviations": [
        "No PR and no PR-assignee write, because the result is a decision (the dispatch says pr is null in that case). I pushed claude/issue-98-requester-sys-file-read empty as the write probe. It is identical to origin/main 46e65f0 and carries no commits; keeping or deleting it is the PM's call.",
        "Symptom wording: the card says 'no file'. On this run the Console showed the unhydrated id as a generic 'File' link (href /api/v1/storage/files/ID). The name, size and type are missing, and the download works. The server log still says 'will render as \"no file\"'. The #87 run's screenshot 036 may show a different surface (the intake wizard); I did not re-drive it.",
        "I made the requester uploads through REST (presigned, PUT, complete, then POST clm_contract_version as the requester), not through the intake wizard, and created the two contracts through REST as each requester. So objectui#12082 (the create-only form) was not exercised.",
        "Temporary mutations to src/profiles/requester.profile.ts were held only for measurement and then restored (see tests). My first apply attempt was refused by ablation-replace itself: the replacement still contained the anchor, so the declared drop was 0, not 1. The tool restored HEAD on its own, and that attempt measured nothing. Before running the gates I deleted two leftovers: a CLI bundle temp (objectstack.config.bundled_opnxbxwk5qf.mjs, which held the TEMP marker), left by a boot I SIGTERMed, and the gitignored dist/.",
        "The gates ran under os-verify-lock as one ';'-sequenced command, so the lock's VERDICT is only the batch's last exit. The per-gate exit files are the readings: all four are 0.",
        "Accounts made with admin/create-user start with PASSWORD_EXPIRED. Each changed its password once through /api/v1/auth/change-password before any measurement.",
        "I did not measure the finance, records, executive or GM audiences one by one. That they are affected comes from source: no clm_* set and not member_default names sys_file. Measured explain allowed=false for BR1-3 and LC1; LC1 also measured raw ids.",
        "I re-ran pnpm demo twice to reboot with each temporary grant. With BR1-3 now existing, each re-run handed the seeded contracts to them. This is the local database only, and it is removed with the worktree."
      ],
      "open_questions": [
        {
          "question": "How should an audience that can read a record see the name, size and type of a file on it? Four fields are affected: clm_contract_version.file, clm_signature.executed_file, clm_obligation.evidence and clm_contract_type.template_file. Measured on 17.7.0: every audience except the platform admin sees a raw id, because hydration reads sys_file with the caller's own context and no clm_* set grants sys_file. A read grant on sys_file is NOT scoped by the parent record: it serves every sys_file row in the organization. The bytes door (/api/v1/storage/files/ID) does follow the parent record and works today for every reader of the record. Permission scope is the maintainer's call, per the zone-1 ruling and DESIGN.md section 04.",
          "options": [
            "A: Wait for the platform fix, objectstack#22590 item 1, with no app change. #98 gets 'Blocked-by: objectstack-ai/objectstack#22590'. The fix shape is the platform's choice: either hydrate the batched sys_file read for ids taken from records the caller was already allowed to read (metadata only; the bytes stay behind the /files/ID parent check), or add a sys_file read-visibility filter on ref_object/ref_id like sys_attachment's. BUSINESS NEED: real; every non-admin audience is affected (measured: BR1-3 and LC1). Until the fix, readers see 'File' instead of the file name, but the file still opens for every reader of the record (measured: 302 on LC1's redline on BR1's contract, 403 on BR2's file). LONG-TERM: the fix lands once for every app with file fields, and there is no app grant to retire later. It is contract-first and has no workaround. AI ERRORS: the rule stays 'if you can read the record, you can see its files'. No app has to learn a sys_file grant, the tenant-wide over-grant an AI would copy into every app. STARTUP FOCUS: zero app code and no new surface. COST: the labels stay missing until objectstack ships #22590 (open, priority:p2, pm:queue).",
            "B: Grant allowRead on sys_file in clm_requester now; every audience holds that set. BUSINESS NEED: fixes the display for every audience today, plus the template_file read shape behind #99 item 2 (measured). COST, measured: every employee can list every file row in the organization with GET /api/v1/data/sys_file, including files on contracts they cannot open (BR3, who owns neither contract: total=4). Each row shows the file name (names often carry the counterparty), the storage key, size, MIME type, the owning record (ref_object/ref_id/ref_field) and the uploader. The bytes stay protected. This is wider than section 04 as the zone-1 ruling reads it ('a grant must not let any audience read files of records it cannot read'), and in a shared tenant it also exposes other apps' files. LONG-TERM: a standing over-grant that has to be removed when the platform ships. AI ERRORS: worst of the three. It declares a sys_file read whose real scope is the whole tenant: the same trap PR #88 deliberately avoided for sys_approval_request. STARTUP FOCUS: one line to add, and one line that someone has to remember to remove.",
            "C: Grant allowRead on sys_file plus an uploader-only RLS (select, owner_id == current_user.id) in clm_requester, shipped as a temporary fixture that names objectstack#22590 (the AGENTS.md platform-gaps row allows that). Measured: the rows are scoped (cross-requester ids 404, BR3 total=0), and a requester's own upload hydrates, which matches the card's literal title. But it resolves only files the reader uploaded. Legal's redline on BR1's contract stays a raw id for BR1, LC1 sees every requester upload as a raw id, and template_file stays a raw id for every non-admin. RLS cannot follow the parent record, because cross-object predicates are refused (ADR-0055), so a fuller app-side version does not exist. BUSINESS NEED: met only for uploaders' own files. Section 03 has legal upload the negotiation rounds, so the versions a requester reads most stay unlabeled. LONG-TERM: an app-side workaround of a platform gap, which the lane ruling forbids, and one more thing to retire. AI ERRORS: a silent partial fix. It passes the uploader's own test and fails for every other reader: the kind of half-truth that gets shipped as 'fixed'. STARTUP FOCUS: small, but new surface. A variant, overriding sys_file's sharingModel to private from the app, gives the same uploader-only scope by redefining a platform object. It is worse, and I did not measure it."
          ],
          "recommendation": "A. BUSINESS NEED: the need is real, but today's harm is a missing label, not a lost file: every reader of the record can open the file now. B would pay for the labels by showing every employee the names of every contract file in the tenant, and file names are exactly the kind of counterparty detail section 04's FLS rows protect. LONG-TERM: A is the contract-first fix, made once in the platform for every app (HotCRM's file fields hit the same path). B and C are app-side patches with a removal date. AI ERRORS: A keeps 'read the record, see its files' as the only rule. B teaches a tenant-wide grant, and C teaches a partial fix that looks complete in the uploader's own test. STARTUP FOCUS: A needs no app code. If the maintainer judges the labels urgent before the platform fix lands, C is the only scoped option, and it covers uploaders' own files only. I do not recommend B, because it is wider than section 04."
        }
      ],
      "out_of_scope_findings": [
        "carrier: the repo:hotclm PM seat, as evidence for objectstack#22590 item 1; I have no write budget there. Three facts. (1) On 17.7.0 the Console renders an unhydrated file id as a generic 'File' link whose href (/api/v1/storage/files/ID) still downloads for a reader of the record, while the server log says it 'will render as \"no file\"'. (2) The measured reason an app grant cannot be the fix: sys_file declares no sharingModel, so plugin-sharing's effectiveSharingModel treats this sys_ object as public with no row filter, and service-storage's installAttachmentReadVisibility covers sys_attachment only. (3) Both are unchanged on objectstack main 25be876 (packages/objectql/src/engine.ts resolveFileReferences still reads sys_file with the caller's context). Noted, not filed.",
        "carrier: #99 item 2 (Draft from template), the zone-2 assumption 2 measurement. template_file reads as a raw id for every non-admin (BR1-3 and LC1 measured) and as an object for the admin. It becomes an object under the unscoped grant (option B) and stays a raw id under option C. So #99 item 2 depends on this card's decision and on the same platform item. Noted, not filed."
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review — needs_decision answered under an existing ruling: A, wait for the platform · 2026-10-10T04:02Z

    The dev's report above (no PR, tree restored byte-identical) measured the scope proof negative: a sys_file read grant is not scoped by the parent record — three requesters, one of whom owns neither contract, each listed all 4 sys_file rows in the organization and read the others' rows by id (200: file name, storage key, ref_object / ref_id, uploader). Only the byte download stays parent-scoped (403 FILE_DOWNLOAD_DENIED). Cause in the platform: sys_file declares no sharingModel (treated as public, no row filter), and parent-record read visibility is installed for sys_attachment only — unchanged on objectstack main 25be876.

    Why the seat answers rather than adding to the decision box: the options are already decided by standing rulings.

    State: pm:blocked, Blocked-by: objectstack-ai/objectstack#22590. Unlock criterion: the fix is in a published @objectstack/* release this repo has installed, and a requester sees their version's file name without any app grant — re-measured, not assumed. #99 item 2 ("Draft from template") depends on the same platform item. The dev's three platform facts are added to #22590.

    The maintainer can overrule this at any time (e.g. choose C as a stop-gap); say so on this card.


    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