Skip to content

Record form: a row-conditioned field lock (when) disables the field on every row, though the write gate admits it on rows the condition does not name #26594

Closed

Description

@objectstack-fleet

Ruled: 6457467702 · letter B · 2026-10-28T00:42Z

Filing gate: ① user-visible defect against a declared limitation, measured on @objectstack 18.2.0 (hotclm main, README-made personas, zh-CN console).

Symptom. hotclm's clm_finance locks the contract's legal fields and status only from approved onward, through a field permission when (#25866, shipped in 18.2.0; hotclm PR #166). A finance controller can therefore fill those fields on the draft they launch.

  • The write gate agrees. The launch wizard writes them, and PATCH clm_contract with 摘要 / 适用法律 / 保密期限 / 管辖 on Finance Controller 1's own draft answers 200.
  • The console disagrees. On that same draft, the record page's 编辑 dialog shows exactly those fields disabled.
  • For the records manager (clm_records, locked from signing onward), every field but 档案号 is disabled on their own draft.

The user is told they cannot edit what they are allowed to edit.

Why. The 18.2.0 FieldPermissionSchema.when description says the readers that hold no row (the record form's field projection, /auth/me/permissions) report a conditioned entry as its lock on every row. The limitation is declared; this card asks to lift it for the record form, which does hold the row.

Expected. The record form's field projection judges when against the record being edited (the stored row, as the write gate does), so a conditioned lock disables a field only on rows it names. /auth/me/permissions, which has no row, may keep reporting the lock.

Evidence. hotclm os-dev-report objectstack-ai/hotclm#127 (comment) and PR #166's Acceptance notes:

  • fc1 own draft: the 编辑 dialog disables the legal fields and 状态, while saving 标题 answers 200 and a PATCH of the legal fields answers 200;
  • rm1 own draft: only 档案号 is enabled.

Business reason. The point of a row-conditioned lock is "locked from this stage on". Every app that uses it, such as hotclm finance and records or a CRM's "lock the price after approval", shows the lock on rows where it does not apply.

Consumer. objectstack-ai/hotclm #127 (closed by PR #166; this is the residual).

Duplicate search. objectstack issues, all states: "form field projection when", "row-conditioned field permission form disabled", "row-blind field readers". Related, closed: #25866 (the when itself) and #26178 (docs recipe). No duplicate.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    on Oct 27, 2026
  2. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: audit line · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T09:56Z

    ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Lane question to triage: pm:retriage · domain:ui seat 2 · session_01J8VVAkHqAv2eFKVSt9LqkC · 2026-10-27T12:59Z

    ⛔ Not a claim, ⛔ not a dispatch. This card stays in pm:queue; seat 2 asks triage to re-route before anyone claims it.

    What is asked: confirm the lane, or move it. Readings on origin/main f6ffd72347:

    1. The field projection the record form reads is computed on the server: packages/plugins/plugin-security/src/security-plugin.ts, resolveProjectionFieldMask (its doc block: "The derivation both field projections share … the caller's permission sets"). That is domain:server.
    2. The row-blind answer the card asks to lift is declared contract text, not a ui bug: packages/spec/src/security/permission.zod.ts line 992, the when description: "Readers that hold no row (/auth/me/permissions, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses." Changing what those readers report is a domain:spec edit first.
    3. A ui-only fix would have the console judge when on the client against the row. That is a second reading of the write gate on the consumer side, which the triage line on this card (「the server's answer wins」) and contract-first both rule out.

    So the work looks like spec (re-declare which readers judge when against a row) plus server (a row-addressed field projection the record form can ask for), with a small ui consumer step after. Whether the declared limitation should be lifted at all may be a maintainer decision, since it changes a published contract's description.


    Generated by Claude Code

  4. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Oct 27, 2026
  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Retriage answer: the card asks to change a declared contract — decision to the maintainer · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T13:56Z

    ⛔ Not a claim. Seat 2's readings hold: the row-blind lock is declared (packages/spec/src/security/permission.zod.ts:992: readers that hold no row "report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses"). The console follows the contract, and the projection is computed server-side (resolveProjectionFieldMask). A ui-only fix would make the client judge when itself, which the contract rules out. → domain:spec, needs-user-decision, pm:awaiting-maintainer.

    维护者速读

    hotclm 的财务岗只在合同「已批准」之后才锁字段(字段权限的 when 条件)。服务端在草稿上允许写,但编辑表单把这些字段全部禁用了。原因不在 ui:协议明文规定,读不到具体记录的接口(/auth/me/permissions、字段投影)一律按「锁定」上报带条件的权限,保证前端永远不提供一个会被拒的编辑。请裁:A 维持协议,按设计关卡,hotclm 先用「保存时由服务端拒绝」的提示;B 给字段投影加一个「按某条记录」的读取(带记录 id),对那一行求值 when,表单据此开放字段——这是协议新增(p1),也是安全边界的变动;C 让前端自己求值 when——协议明文排除,不建议。选 A / B / C?

    Option Consequence Workload
    A keep the contract The form stays locked on drafts where the gate allows. Users must know to ask. none (close by design)
    B a record-scoped projection read The form opens exactly the fields the gate admits on this row. One more read per form open. spec + server · 1 PR (protocol addition, p1) + ui reader
    C the client evaluates when The client and the gate can disagree, which the contract forbids. not recommended

    Recommendation: B. It keeps the contract's promise (the client never advertises an edit the gate refuses) and lifts the row-blindness, by asking the gate about this row instead of guessing.

    os-decision-facets
    ① 项目长远合理性:B 把「按行判断」交给服务端,不新增前端判断逻辑;A 保持现状不扩散;C 违背协议。
    ② 实际业务拉动:hotclm 财务岗在自己发起的草稿上,看到本可填写的字段全部禁用(已实测)。
    ③ 防 AI 犯错:B 由服务端对这一行给出唯一答案,前端照读,前后端判断一致;C 会产生两套判断。
    ④ 创业阶段不扩散:B 新增一个读取参数(协议新增),A 零改动。
    Prior rulings read: field permission when · row-conditioned lock · projection → #25866 (when, 18.2), permission.zod.ts:992 describe; ADR none; thread: #26594 (6442500339)


    Generated by Claude Code

  6. 5 remaining items

  7. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-28T01:30Z
    Session: session_01WGufzgbJhFoXYsLSe5WSPP
    Account: os-support-ai
    Branch: claude/project-thread-dyw8uu (card thread; the platform assigns the thread's branch and it can push only that one, written as measured instead of claude/issue-26594-<slug>)
    Worktree: objectstack-issue-26594
    Domain: domain:spec
    Seat: domain:spec#3
    Ruling-ref: 6457467702 (letter B: a record-scoped field projection read; the server judges when against the stored row as the write gate does; row-less readers unchanged; the client never evaluates when)
    File surface: PR 1 — packages/spec/src/security/permission.zod.ts (the when re-declaration) and the projection read's contract (parameter or record-addressed route, the dev states which), packages/plugins/plugin-security/src/security-plugin-field-readers.ts and the route that serves the record form's projection, their tests, any API doc for the read, one changeset per touched published package. PR 2 — the ui record form's edit-dialog projection reader and its tests (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: TIER_DEFAULT — --tier: no path-derived mandate (protocol addition; one CONTRACT_REVIEW_TIER review owed per PR, run by the seat), effort: high
    Clause-②: yes (widening)
    Metadata-protocol: added
    Responsibility: the spec lane owns the projection contract and its door (ruling: "the door's shape … is the spec lane's"); vertical through the ui reader in PR 2
    Thread-read: 6457467702
    Premise: holds on origin/main 7eb613c3: permission.zod.ts ~:995 declares the field projections row-blind ("Readers that hold no row … report a row-conditioned entry as its lock"); the dev reproduces a draft row whose projection locks a field that a PATCH on the same row writes (200) before editing
    Serial constraints cleared: no open PR on these files that the seat knows of (the dev re-checks before editing)

  8. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment: the door's shape and the file surface · domain:spec seat 3 · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T01:43Z · same claim (6458235349), same branch claude/project-thread-dyw8uu.

    Measured by the dev at start. The record form's edit dialog does not read resolveProjectionFieldMask. It reads /auth/me/permissions (MePermissionsProvider → checkField). No HTTP route serves the field projections at all. The ruling lets the spec lane choose between a parameter on an existing projection read and a record-addressed route. With no projection route to add a parameter to, the shape is a record-addressed route:

    • The route. GET /api/v1/data/:object/:id/field-access answers { object, recordId, readable, editable }. It is backed by a new optional ISecurityService.getRecordFieldAccess.
    • One judge. The editable half is the write gate itself: computeForbiddenFieldWrites, run over an update of the row the caller read, naming every field. It is not a second evaluator of when.
    • Never wider than the read gate. The route reads the record through getData first, so a hidden or missing row gets the same 404 the record read gives.

    File surface added:

    • packages/plugins/plugin-security/src/security-plugin.ts and security-plugin-security-service.ts (the method and its service registration)
    • the ISecurityService contract in packages/spec (the optional method and its doc)
    • packages/rest/src/data-action-routes.ts and rest-route-ledger.ts (the route, ledgered sdk)
    • packages/client/src/index.ts (data.fieldAccess, needed by the ledger and by PR 2)
    • content/docs/permissions/field-level-security.mdx
    • a dogfood test and its fixture

    No skills/** path is added, so the PR is not governed. Clause-②: yes (widening) and Metadata-protocol: added stand. The Skills-docs: line is still owed.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26594,
    "status": "done",
    "branch": "claude/project-thread-dyw8uu",
    "pr": "#27063",
    "session": "session_01WGufzfXsZBPnnuWWQGXm48",
    "premise_still_valid": true,
    "summary": "PR 1 of 2 (Part of #26594, head 3af86ea): GET /api/v1/data/:object/:id/field-access + client.data.fieldAccess + ISecurityService.getRecordFieldAccess + GetRecordFieldAccessResponseSchema; editable = SecurityPlugin#computeForbiddenFieldWrites on the caller-read stored row (one judge), readable = getReadableFields' mask; missing/unreadable row = GET /data/:object/:id's 404; /auth/me/permissions and getWritableFields unchanged; permission.zod.ts when describe names the record-scoped reader. Premise measured: the ui edit dialog reads /auth/me/permissions (MePermissionsProvider -> fieldWriteGate) and no HTTP route served the field projections, hence a record-addressed route. PR 2 (ui reader, Fixes #26594) waits for PR 1 to merge; the branch restarts from main then.",
    "tests": "dogfood record-field-access-when 7/7 (exit 0, after last main merge); plugin-security test exit 0 (5056 passed pre-merge), rest test exit 0 (6552 passed pre-merge), client test exit 0 (679 passed pre-merge), spec test exit 0 (22130 passed, pre-merge), all typechecks exit 0. Ablation: preImage -> null row, marker in dist, dogfood 2 failed / 5 passed (draft unlock, field-by-field equality); restored: marker absent from 6 rebuilt dist files, 7/7.",
    "gates": "dispatch-gates --commands: 109 derived, 109 exit 0 on head tree; --ran: 109 run, 0 NOT-MEASURED, 0 UNRUN. Pre-merge round: check:skill-examples and check:dual-build-cjs-loads exit 3 (missing dist of untouched packages), built, rerun exit 0. spec check:generated exit 0. CI on #27063: in_progress at report time.",
    "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
    "mcp_calls": "4 — mcp__github__issue_read x3, mcp__github__pull_request_read x1 (reads only)",
    "api_writes": "8 — git push x5 (refs/heads/claude/project-thread-dyw8uu); relay repository_dispatch x3: pr_create #27063 (assignees os-support-ai), pr_request_reviewers #27063 (os-support-ai), comment #26594 (this report)",
    "open_questions": [],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #27063 comment 6461804547 · domain:spec seat 3 · 2026-10-28T05:52Z

    PR 1 of 2: the record-scoped field-access route, ruling B. The contract review passed (6460514485). The PR is ready, and auto-merge is armed. Next: PR 2, the ui edit dialog, which carries Fixes #26594. Filed out of scope: #27075 (the ADR-0066 note).


    Generated by Claude Code

  11. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed in part (PR 1 of 2) · domain:spec seat 3 · os-support-ai · 2026-10-28T06:28Z

    reading value
    PR #27063 merged through the queue at 2026-10-28T06:26Z, merge commit f9f27a06f7
    origin/main 50b1b7d8 contains the merge commit (git merge-base --is-ancestor)

    Delivered (ruling B, 6457467702): GET /api/v1/data/:object/:id/field-access returns { object, recordId, readable, editable }, and editable comes from the write gate's own computeForbiddenFieldWrites run on the stored row. The client gains data.fieldAccess. The when description names this route as the reader that judges the row. Row-less readers are unchanged.

    Next on this card, under the same claim and in the same thread: PR 2, the ui record form's edit dialog reading this route, with Fixes #26594. It also measures the case where the row itself is not writable. pm:dispatched stays. Filed out of scope: #27075 (the ADR-0066 note, Tier H).


    Generated by Claude Code

  12. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 26594,
      "status": "pr_open",
      "branch": "claude/project-thread-dyw8uu",
      "pr": 27137,
      "head": "90f1ea4dfcd887396d1f092d77298ee6e07ce463",
      "session": "https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48",
      "premise_still_valid": true,
      "summary": "PR 2 of 2 (Fixes #26594): ModalForm (the console edit dialog), ObjectForm and DrawerForm ask DataSource.getRecordFieldAccess for the stored record and answer only the field-level write lock from it through RecordFieldAccessScope; object grant, owner transfer, read/create and other objects keep the row-blind answer; no answer keeps the lock. Row-not-writable measured: field-access lists fields editable on a public_read row the PATCH refuses (403); the ui has no record-level write gate (managedModeLocked reads the object grant only), so the dialog offers the fields and the server refuses the save, as before; schema, ui types and the FLS doc say so.",
      "tests": {
        "plugin-form recordFieldAccess-26594": "10/10",
        "permissions RecordFieldAccessScope-26594": "7/7",
        "data-objectstack getRecordFieldAccess-26594": "4/4",
        "dogfood record-field-access-when": "8/8",
        "plugin-form suite": "2423 passed",
        "permissions suite": "158 passed",
        "data-objectstack suite": "1209 passed",
        "ablation": "6 red / 4 green"
      },
      "gates": {
        "derived": 93,
        "run": 93,
        "not_measured": 0,
        "unrun": 0,
        "exit_nonzero_first_run": [
          "pnpm --dir ui exec vitest run scripts/"
        ],
        "rerun_after_fix_exit": 0
      },
      "dogfood_battery": "record-field-access-when.dogfood.test.ts 8/8 under os-verify-lock; dogfood typecheck exit 0",
      "mcp_calls": 17,
      "api_writes": 3,
      "open_questions": [],
      "out_of_scope_findings": [
        "Record detail inline edit (plugin-detail DetailSection.tsx:507, HeaderHighlight.tsx:211) holds one row and still reads the row-blind lock.",
        "No ui surface gates the edit dialog on the record's row-level write scope; doing so needs a record-level canEdit from the server."
      ]
    }

    Generated by Claude Code

  13. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #27137 comment 6464763665

  14. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:spec seat 3 · os-support-ai · 2026-10-28T09:10Z

    reading value
    PR #27063 (PR 1) merged at 2026-10-28T06:26Z, merge commit f9f27a06f7
    PR #27137 (PR 2) merged through the queue at 2026-10-28T09:09Z, merge commit 6866e2e7c5
    origin/main 6866e2e7 contains both merge commits (git merge-base --is-ancestor)

    Delivered (ruling B, 6457467702): the console's edit dialog, edit page and drawer form now read the record-scoped field projection. A when-conditioned lock disables a field only on rows the condition names. The projection is asked again when the form re-reads the record (#27147). Until the answer lands, and when there is none, the row-blind lock stands. The client never evaluates when.

    Follow-ups, each filed on its own card: #27146 (record detail page inline edit still row-blind), #27138 (the dialog never asks whether the row itself is writable), #27075 (ADR-0066 note, Tier H). pm:dispatched is removed here and on #27147.

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:specpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions