Skip to content

feat(spec/plugin-security): a field permission entry can carry a row condition, so a set's field lock applies only on the rows it names #25866

Closed

Description

@zhuangjianguo

Premise checked before dispatch (domain:spec seat 2, 2026-10-26): readings and corrections in comment 6409890004 (#25866 (comment)). The goal below is unchanged.

Ruled: 6408558941 · letter A · 2026-10-26T05:02Z

Filing class: ③ capability gap, from a consuming app — reach: public door (REST create_record / update_record, flow nodes), measured on 18.1.0. Filed by the hotclm decision-box director on the maintainer's ruling (objectstack-ai/hotclm#127, letter A, 2026-10-26T01:47Z).

What a metadata app cannot say today

FieldPermissionSchema (packages/spec/src/security/permission.zod.ts) has two booleans, readable and editable, keyed object.field on a permission set. A field lock therefore applies to every row of the object a holder can reach. Row-level security (rowLevelSecurity[].using) decides which rows may be written, but cannot name a field. There is no way to express:

"This set may edit clm_contract, but on rows where status is approved or later it may not write governing_law, summary, confidentiality_term_months, status."

Why it matters (measured)

hotclm, 18.1.0, PR objectstack-ai/hotclm#126 head 19e8dac:

  • Every member holds clm_requester (may launch a contract, may edit it while draft / submitted).
  • A finance controller also holds clm_finance: allowEdit on clm_contract, rows delivered from approved onward by a sharing rule plus a row policy, and a field lock on the legal fields and status so finance cannot rewrite terms after approval.
  • Because the lock is per set and per object, it also lands on the draft the finance controller creates:
400 Node 'create_contract' failed: create_record(clm_contract) failed:
[Security] Field write denied: not permitted to edit
[confidentiality_term_months, governing_law, status, summary] on 'clm_contract'
  • PermissionEvaluator.getFieldPermissions merges most-permissively, but only sets that declare the field vote, so a co-held set that says nothing (clm_requester) does not lift the lock. Declaring the fields open on clm_requester would lift finance's post-approval lock everywhere, which is the lock's whole purpose. The app has no fix that keeps its own security design.
  • The same shape hits any "RU from stage X onward, legal fields frozen" position (hotclm's records set uses editableOnly the same way and is expected to fail identically).

Ask

A field permission entry accepts an optional row condition, in the same expression language RLS using already parses, meaning: this entry votes only on rows matching the condition; on other rows the entry is absent (so the existing most-permissive merge and "declaring sets vote" rule are unchanged). Sketch, not a design decision:

fields: {
  'clm_contract.governing_law': { readable: true, editable: false, when: 'status in ("approved","signing","active","expired","terminated")' },
}

Points the maintainer may want to rule on, named so the dev does not guess:

  1. Which row the condition reads. On update: the stored row before the write (a lock on status in (approved…) must bite on an approved row even when the write tries to change status). On create: the incoming row.
  2. Read side. readable with a condition would need the field masker to evaluate per row on list reads; if that is out of scope, the first cut may accept the condition on editable only and refuse it on readable at parse time, as long as the spec says so.
  3. os validate / os explain: the condition is validated like an RLS predicate (field names exist on the object, no cross-object), and explain shows the row-conditioned lock as such.
  4. Studio permission-set form offers the key.

Until this ships, hotclm's finance and records positions cannot launch a contract (objectstack-ai/hotclm#127 holds Blocked-by: on this card).

Dedupe

Searched objectstack issues for field permission row condition, conditional FLS, per-record field lock, FLS predicate: #23549 (empty object part in a field key, closed), #4376 (row-level read scope, closed), #7900 (automation read domain FLS, closed). None asks for a row condition on a field permission.

Activity

  1. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Triage: first grade — enhancement · security · priority:p1 · domain:spec · area:access · needs-user-decision · pm:awaiting-maintainer · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-26T01:55Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • For the maintainer: a new protocol key on a security boundary: a field permission that votes only on the rows a condition names. The hotclm#127 ruling (A) sent the capability here, and the key's shape and semantics are still unruled. Both a protocol key and a security boundary are on the human floor. p1 by 「协议新增 p1」, and hotclm#127 is blocked on it.
    • Options:
      • A, editable only first (triage leaning). when is accepted on editable and refused at parse time on readable. On update it reads the stored row before the write; on create, the incoming row. It is validated like an RLS using predicate (same-object fields only), and os explain shows it. The default is unchanged: an entry without when votes on every row. Workload: spec key S, plugin-security write gate M, lint/explain S, Studio form S. About 3–4 dev-days.
      • B, readable and editable together. As A, plus per-row masking on list and read paths. That is a wider surface on the read hot path. Workload: A plus about 3 dev-days, and read-path performance has to be measured.
      • C, no new key. The app splits its sets by stage instead. Measured on hotclm: no app-side fix keeps its own security design (co-held sets do not lift a declaring set's lock).
    • Lean: A. It covers the measured need (a write lock from a stage onward), keeps the read path unchanged, and leaves B open as a later widening.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Brief-claimed-by: session_01KrKXQVvgYk2AJ4VkqMobyb · batch #392 · 2026-10-26T04:12Z

    Director seat, summon #51 (ledger objectstack#12708). This card is presented to the maintainer in batch #392 as item 3 of 5. Seat increment (what was re-verified before presenting; the card's own analysis is not restated):

    • Spec at origin/main packages/spec/src/security/permission.zod.ts:864: FieldPermissionSchema is a strictObject with readable / editable only (aliases read/visible/write/edit/update), no row-condition key — the card's premise holds. RLS using is a parsed expression over current_user.* (rls.zod.ts header), so 'the same expression language' names a real parser.
    • git grep -iE 'FieldPermission|rowLevelSecurity' on AGENTS.md + docs/adr + packages/spec/src → ADR-0066 ⑧ (FLS is block-list shaped and field grants union without deny — the exact mechanism the hotclm case hits), ADR-0086 (FieldPermission is metadata riding the set, own fields only), ADR-0056, ADR-0106 (metadata-plane FLS masking). None rules on a row condition; none forbids it.
    • Half-state read, not written: the card carries both needs-user-decision and pm:awaiting-maintainer (first grade 6406498770). Under the state model it is one or the other; flagged for the triage seat, no label written by this seat.
    • Facets read: same direction. The seat agrees with A (when on editable only, refused at parse time on readable; pre-image on update, incoming row on create; validated like an RLS predicate). Mainstream reference for the maintainer: Salesforce and Dynamics FLS carry no row condition — stage locks there are validation rules or approval locks — so this key is a deliberate widening of the FLS contract; it stays narrow by being spec-declared and loud, which is why A over B.
  3. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Ruling: batch #392 item 3 · letter A · maintainer 「同意」 2026-10-26T04:55Z

    Recorded by the director seat (summon #51, session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708, brief 6408059125). A conditional ruling, three parts:

    ① Route A. FieldPermissionSchema gains an optional row condition (when, the same expression language the RLS using predicate parses) on editable only; a when on readable is refused at parse time and the spec says so. On update the condition reads the stored row before the write; on create, the incoming row. It is validated like an RLS predicate (field names exist on the object, no cross-object reach), os explain shows the row-conditioned lock as such, and the Studio permission-set form offers the key. Defaults unchanged: an entry without when votes on every row; on rows the condition does not match the entry is absent, so the most-permissive merge and the "declaring sets vote" rule stay as they are. Not B (per-row masking on the read hot path waits for a measured need) and not C (measured on hotclm: no app-side split keeps the app's own security design).

    ② Premises the dev verifies before writing code (each with its re-check): (a) FieldPermissionSchema on origin/main has no condition key — git grep -n '\bwhen\b' packages/spec/src/security/permission.zod.ts → 0 (positive control: editable → hits); (b) the RLS using evaluator in plugin-security can judge one predicate against one row object (the pre-image or the incoming row) without rewriting a query — read the evaluator, name the function; (c) hotclm's shape reproduces on 18.1.0 as the card measures (objectstack-ai/hotclm#126 head 19e8dac).

    ③ If any premise fails, the dev reports a fork (needs_decision) — ⛔ no forcing, ⛔ no silent fallback to B or C.

    Workload of the chosen letter (card column): spec key S · plugin-security write gate M · lint / explain S · Studio form S — about 3–4 dev-days (triage estimate 6406498770).

    Execution: needs-user-decision → pm:queue in this act (domain:spec, p1, security); pm:awaiting-maintainer is removed in the same stroke — it sat beside the decision label as a half-state with no Maintainer-action: line, nothing was awaiting a human act. The dispatch carries Ruling-ref: naming this comment; Clause-②: yes (widening), Metadata-protocol: added, minor on @objectstack/spec; the PR adds a dated note under ADR-0066 ⑧ saying the row condition narrows where an entry votes and does not change the union-without-deny posture. objectstack-ai/hotclm#127 unblocks on a published release carrying this.

  4. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Premise check before dispatch · domain:spec seat 2 · 2026-10-26T06:38Z

    Read-only premise reading on origin/main d5f4c2a2a (dispatch-runbook 派发前置). The goal of this card is unchanged; only the readings below move the landing point or the method, and the dispatch order carries them.

    CORRECTED on (a): git grep '\bwhen\b' in permission.zod.ts hits 7 prose lines; the real test is structural: FieldPermissionSchema (permission.zod.ts:~864, strictObject, readable/editable only) has no condition key. (b) holds: judge a row in-process via RLSCompiler.compileExpression (rls-compiler.ts:~1155) then matchesFilterCondition (formula/src/matches-filter.ts:~331), as storedFormCheckJudge does (rls-check-stored-form.ts:~539). ⛔ Do not reuse the ql.findOne query-rewrite of the using gate (authz-middleware ~1030). A predicate that compiles to null is refused at parse/validate, never at runtime. (c) denial raised at security-plugin-authz-middleware.ts:~1194 from computeForbiddenFieldWrites (security-plugin-object-inputs.ts:~274), row-blind today; second caller canWriteObject (security-plugin-object-admission.ts:~338); merge in PermissionEvaluator.getFieldPermissions (permission-evaluator.ts:~429, most-permissive OR).

    Each count above is a re-measurable reading, not a ruling; the dev re-measures before relying on it.

  5. 1 remaining item

  6. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1 · 2026-10-26T06:48Z
    Session: session_01RyMY3prh9GRva1Tt37Di3i
    Account: zhuangjianguo
    Branch: claude/project-thread-xnz0xa (card thread; the platform assigns the thread's branch and it can push only that one, written as measured instead of claude/issue-25866-<slug>)
    Worktree: objectstack-issue-25866
    Domain: domain:spec
    Seat: domain:spec#2
    File surface: packages/spec/src/security/permission.zod.ts (+ generated spec artefacts), packages/plugins/plugin-security/src (permission-evaluator, object-inputs, authz-middleware, object-admission, explain), packages/lint/src/validate-security-posture.ts, packages/core/src/security/effective-object-permissions.ts, docs/adr/0066-unified-authorization-model.md, packages/lint/src/validate-rls-predicate-enforceability.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, the generated spec artefacts (authorable-surface, liveness, dropped-refinements baseline), the docs pages content/docs/permissions/field-level-security.mdx, protocol/objectql/security.mdx, deployment/validating-metadata.mdx and the generated reference pages, the Studio permission-set form ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx (+ its tests; the ruling's Studio clause, patch round), one changeset; amended by the seat after the thread reported these four landing-point deviations (stop on breach; explain in the report)
    Container & model: L, mode:thread, model: CONTRACT_REVIEW_TIER — MANDATORY: docs/adr/0066-unified-authorization-model.md ⇢ 'docs/adr/**', effort: xhigh
    Clause-②: yes
    Metadata-protocol: added
    Responsibility: n/a — not a defect card
    Thread-read: 6409890004
    Premise: corrected 6409890004
    Serial constraints cleared: none (no open PR or in-flight claim touches plugin-security or packages/spec/src/security)

  7. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    Posted: 2026-10-26T08:18Z

    {
      "report": "os-dev-report",
      "card": "objectstack-ai/objectstack#25866",
      "claim": "6410022810",
      "ruling_ref": "6408558941",
      "branch": "claude/project-thread-xnz0xa",
      "pr": "objectstack-ai/objectstack#25980",
      "head_sha": "3d3775861b6d3c6dbf87402fbe71d64703127399",
      "commits": [
        "94d1fde683748f4b20e7ee88b99b468fd5e299a0 feat(spec,security): a field permission entry can carry a row condition (`when`) on its edit vote",
        "46400bf09a6b626a5d778c103a048a508bf1e56d docs(permissions): the two hand-written FLS pages describe the `when` row condition",
        "3d3775861b6d3c6dbf87402fbe71d64703127399 docs(deployment): the validation matrix names the field permission `when` the RLS predicate rule judges"
      ],
      "verdict": "PASS — implemented per ruling letter A; gates 127/127 green; CI on head green so far (3 Test Core shards + Type Check UI still running at posting time)",
      "declarations": {
        "Clause-②": "yes (widening)",
        "Metadata-protocol": "added",
        "Ruling-ref": "6408558941",
        "changeset": ".changeset/25866-field-permission-row-condition.md — minor on @objectstack/spec, @objectstack/core, @objectstack/plugin-security, @objectstack/lint, @objectstack/plugin-hono-server"
      },
      "landing": {
        "tier": "H (docs/adr/0066 note) — PR opened as DRAFT via relay pr_create with assignees [zhuangjianguo]; the governed-review follow-up ran on the relay side",
        "state_now": "os-zhuang approved (review 5648858472, 07:50:54Z), marked ready for review (07:51:01Z) and enabled auto-merge (merge, 07:51:05Z) — none of these are this seat's writes. Two docs pushes followed the approval (46400bf0, 3d377586); if branch protection dismisses stale approvals, os-zhuang's approval is needed again."
      },
      "what_landed": {
        "spec": "FieldPermissionSchema.when (non-blank CEL string, EDIT vote only; `readable:false` beside it refused at parse with the stated message; condition/where/filter refused with a pointer to `when`); generated artefacts regenerated (authorable-surface/security.json, content/docs/references/api/protocol.mdx, content/docs/references/security/permission.mdx, liveness/state-counts/permission.md); dropped-refinements.baseline.json +security/FieldPermission root site +6 fields.valueType sites as the build printed; liveness/permission.json classifies fields.when live",
        "core": "fieldPermissionVote(entry, verdict ∈ matched|unmatched|unjudged) and buildEffectiveFieldPermissions(sets) (row-blind, lock); bits read === true, byte-identical for entries without when",
        "plugin-security": "RLSCompiler.compileRowPredicate(expr, ctx, fieldGuard?) — ONE predicate through the check clause's compile/field guard/comparand faces; userContextOf extracted from compileFilter (reserved-key refusal kept); PermissionEvaluator.getFieldPermissions(object, sets, judge?) + conditionedFieldEntries; computeForbiddenFieldWrites async with a row argument: insert rows judged on the payload (union refused), by-id update on the caller-read stored pre-image (getCallerPreImage, lazy thunk — read only when a set carries a when), null pre-image / predicate update / unresolvable condition / reader without a row → LOCK + WARN; wired at middleware step 2.5 and canWriteObject (insert on payload, update as lock); explain fls layer names each row-conditioned entry",
        "lint": "validateRlsPredicateEnforceability gains a `when` clause: permissions[].fields[\"<object>.<field>\"].when judged by the using/check passes against the key's object; ids rls-predicate-unenforceable/-unparseable/-over-budget/-unknown-field/-unknown-user-variable; cost sentence: the entry cannot be judged and is applied as its lock on every row",
        "hono": "/auth/me/permissions fields merge = buildEffectiveFieldPermissions(resolved) (row-blind lock)",
        "adr": "ADR-0066 ⑧ indented dated note (2026-10-26, ruling 6408558941): the row condition narrows where an entry votes; union-without-deny unchanged. ADR anchor for permission-evaluator.ts already pins ADR-0066 — no anchor file touched",
        "docs": "content/docs/permissions/field-level-security.mdx (+ section), content/docs/protocol/objectql/security.mdx (+ reference key and note), content/docs/deployment/validating-metadata.mdx (matrix row names the when)"
      },
      "judge_path": "in-process only: compileRowPredicate → storedFormCheckJudge([filter], writeCheckFieldOptions(object)) over matchesFilterCondition. The ql.findOne query-rewrite of the using gate (authz-middleware ~1030/1055) is NOT reused. A predicate that cannot compile at authoring is refused by the lint rule; a request-time cause (unresolved current_user.*, undeclared column, refused comparand) locks the entry and WARNs — never a runtime drop",
      "readers_of_getFieldPermissions": {
        "security-plugin-explain-access.ts getFieldMask/getPartialMaskRules": "row-blind, no judge → conditioned entry is its lock; explain fls layer additionally names it; code unchanged",
        "security-plugin-field-readers.ts :188/:212 (writable-field projection)": "row-blind lock; a form may show a field read-only on a row the gate admits — never wider than the gate; code unchanged",
        "security-plugin-object-inputs.ts computeQueryGuardFieldPerms :460/:466, maskOperationResult": "row-blind lock; read bits are row-blind by contract so the masker's answer is unchanged; code unchanged",
        "packages/lint/src/validate-security-posture.ts :755/:814": "reads key qualification and field existence only — `when` changes neither; the predicate judge landed in validate-rls-predicate-enforceability.ts; code unchanged",
        "packages/core/src/security/effective-object-permissions.ts ~:546": "objects merge untouched; the fields merge now lives beside it (buildEffectiveFieldPermissions) and reads the lock",
        "plugin-hono-server current-user-endpoints.ts ~:900": "calls the core merge; row-blind lock"
      },
      "file_surface_vs_claim": {
        "within_claim": [
          "packages/spec/src/security/permission.zod.ts (+permission.test.ts, generated artefacts, ledgers)",
          "packages/plugins/plugin-security/src/{permission-evaluator,security-plugin-object-inputs,security-plugin-authz-middleware,security-plugin-object-admission,explain-engine,rls-compiler,security-plugin}.ts (+2 tests)",
          "packages/core/src/security/effective-object-permissions.ts (+index.ts, test)",
          "docs/adr/0066-unified-authorization-model.md",
          "one changeset"
        ],
        "deviations": [
          "1. lint landed in packages/lint/src/validate-rls-predicate-enforceability.ts (not validate-security-posture.ts): that rule owns the compiler-backed passes and the five rls-predicate-* ids; a second spelling would be the drift the rule exists to avoid",
          "2. packages/plugins/plugin-hono-server/src/current-user-endpoints.ts touched (inside the Claim's other-readers obligation): it carried an inline copy of the field merge that would otherwise report a conditioned entry as editable",
          "3. generated spec artefacts beyond json-schema: authorable-surface/security.json, two reference .mdx pages, dropped-refinements ledger, liveness ledger + state count",
          "4. three hand-written doc pages (permissions/field-level-security.mdx, protocol/objectql/security.mdx, deployment/validating-metadata.mdx) named by the Docs Drift Check stated a two-key entry / a three-source rule; corrected in two follow-up commits, docs gates re-run green; no release-owned page touched"
        ]
      },
      "not_measured": [
        "Studio form (../objectui is not readable from this seat): the generated JSON Schema / authorable surface carries `when`, so the form's key offer is the schema's; the Studio reading itself is unmeasured",
        "hotclm#126/#127 are not readable from this seat; the tests use the card's shape (a lock on a stage field from approved onward)"
      ],
      "tests": {
        "record": [
          "spec-test :: exit 0 :: pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/security/permission.test.ts",
          "core-typecheck :: exit 0 :: pnpm --filter @objectstack/core run typecheck",
          "core-test :: exit 0 :: pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/effective-object-permissions.test.ts",
          "sec-typecheck :: exit 1 :: pnpm --filter @objectstack/plugin-security run typecheck",
          "sec-test-plugin :: exit 1 :: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/security-plugin.test.ts",
          "sec-test-explain :: exit 0 :: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/explain-engine.test.ts",
          "sec-test-rls :: exit 0 :: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/rls-check-stored-form.test.ts src/rls-accessible-org-ids-plumbing.test.ts src/rls-denial-observability.test.ts src/rls-shared-lowering-seam.test.ts src/rls-deny-sentinel-identity.test.ts",
          "lint-typecheck :: exit 0 :: pnpm --filter @objectstack/lint run typecheck",
          "lint-test :: exit 0 :: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-rls-predicate-enforceability.test.ts src/validate-security-posture.test.ts",
          "hono-typecheck :: exit 0 :: pnpm --filter @objectstack/plugin-hono-server run typecheck",
          "hono-test :: exit 0 :: pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/hono-current-user-endpoints.test.ts src/current-user-endpoints-effective-objects.test.ts",
          "spec-typecheck :: exit 0 :: pnpm --filter @objectstack/spec run typecheck",
          "sec-typecheck-2 :: exit 0",
          "sec-test-2 :: exit 0",
          "sec-typecheck-2 :: exit 0 (after the explain test typing fix)",
          "sec-test-2 :: exit 0 :: security-plugin.test.ts + explain-engine.test.ts — 351 passed"
        ],
        "note": "first sec-typecheck/sec-test-plugin reds were (a) a Partial<ExplainEngineDeps> typing in the new explain test and (b) a middleware test that expected the 2.5 lock on a null pre-image, where step 2.7 answers 404 first; the test now asserts the 404 and pins the gate's own null-pre-image lock on the derivation directly"
      },
      "gates": {
        "derive": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) → 104 at the code diff; 127 once the docs commits widened the path set",
        "run": "each command run verbatim, exit code captured before any pipe (scratchpad run-record), then `--ran` reconciled",
        "reconciliation": "Run reconciliation — 127 derived, 127 run, 0 NOT-MEASURED, 0 UNRUN (dispatch-gates --ran exit 0)",
        "reruns_explained": [
          "node scripts/check-plugin-teardown-shape.mjs --self-test: first exit 3 (fixture pinned to commit 621a4876 unreachable in the shallow clone; same exit 3 on base); after `git fetch origin 621a487607881c66b2899b7e3477115229a156b4` → exit 0 (48 cases)",
          "pnpm check:dual-build-cjs-loads and pnpm check:i18n: first exit 3 (PREREQUISITE NOT MET — unbuilt workspace); after a full `pnpm build --concurrency=2` under the verify lock (exit 0, no tree drift) → both exit 0",
          "docs gates (check:doc-authoring, check:docs-transcript-drift, docs-audit check-affected-docs / check-drift-comment) re-run after each doc page edit → exit 0"
        ],
        "typecheck_per_package": "pnpm --filter <pkg> run typecheck exit 0 for @objectstack/spec, core, plugin-security, lint, plugin-hono-server"
      },
      "ci_check_runs_on_head_3d377586": {
        "success": [
          "TypeScript Type Check 121465021243",
          "Lint & Repo Gates 121461061469",
          "Build Core 121459658392",
          "Build Docs 121459658442",
          "Build UI Deps 121459658455",
          "Build & E2E UI 121461324765",
          "Test Core 1/8 121459658765",
          "Test Core 2/8 121459658737",
          "Test Core 6/8 121459658529",
          "Test Core 7/8 121459658511",
          "Test Core 8/8 121459658541",
          "Dogfood Regression Gate 121459660110",
          "Check Changeset 121459178675",
          "Governed Surface Queue Guard 121458985901",
          "Spec property liveness 121458985328",
          "Flag docs affected by code changes 121458985367",
          "The card this PR closes must claim this branch 121459178733",
          "No other open PR may claim the same issue 121459178510",
          "No other open PR may claim the same single-writer path 121459178978",
          "Part-of PR must not also close its card 121459178215",
          "Check Documentation Links 121458985609",
          "Check PR Size 121458985851",
          "Type Check · source gates / workspace / debt ledger / consumer gates, Lint · ESLint / repo & tooling / docs & code / CI release engine / PM dispatch-gates / scoped ×3 — all success"
        ],
        "in_progress_at_posting": [
          "Test Core 3/8 121459658557",
          "Test Core 4/8 121459658695",
          "Test Core 5/8 121459658619",
          "Type Check UI 121461324733"
        ],
        "earlier_reds_explained": "Lint & Repo Gates / TypeScript Type Check on 94d1fde6 and 46400bf0 concluded failure because their lanes were CANCELLED by the next push (aggregator reads cancelled as not-success); on the current head both are success"
      },
      "api_writes": {
        "count": 5,
        "all_through_relay": true,
        "endpoints": [
          "POST /repos/objectstack-ai/objectstack/pulls (fleet-write pr_create, draft) → #25980",
          "POST /repos/objectstack-ai/objectstack/issues/25980/assignees (pr_create assignees leg)",
          "PATCH /repos/objectstack-ai/objectstack/issues/25980 (fleet-write issue_patch, PR body) ×2",
          "POST /repos/objectstack-ai/objectstack/issues/25866/comments (post-stamped.mjs — this report)"
        ]
      },
      "mcp_calls": {
        "count": 7,
        "github_writes": 0,
        "tools": [
          "mcp__github__issue_read ×3 (get ×1, get_comments ×2 — the card, ruling and Claim)",
          "mcp__github__get_job_logs ×2 (the two cancelled-lane aggregators)",
          "mcp__github__pull_request_read ×1 (get_check_runs on #25980)",
          "mcp__claude-code-remote__subscribe_pr_activity ×1 (#25980)"
        ]
      },
      "thread": "stays open for FAIL/patch rounds and PR events; worktree kept until the PR merges or closes"
    }
  8. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    os-dev-report addendum

    Posted: 2026-10-26T08:34Z

    Correction to the os-dev-report above (comment 6411406490), after contract review 6411620876 on PR #25980 (VERDICT: PASS):

    • not_measured[0] stated a false premise. ../objectui is not where the Studio lives; the Console source is in this tree at ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx. Read at the head: a hand-written two-checkbox matrix (readable, editable) with no when input and no display of an authored one; updateFieldPerm spreads the entry, so an authored when survives a per-field toggle; bulkSetFields rewrites the entry as two booleans for readable / writable and drops the when; the readable-off cascade leaves readable: false beside a when, which the spec now refuses at the publish gate, and the form has no control to clear the condition. Nothing here widens the runtime gate, but the ruling's "Studio permission-set form offers the key" clause is not delivered by PR feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) #25980. Handed to the spec-lane seat to file as its own ui-lane card (offer when, show an authored one, keep it across the bulk shortcuts, clear it on the readable-off cascade).
    • ADR-0066 ⑧ note, last sentence reads "The muting/deny layer ⑦ still owes remains the mechanism …" — two drafts merged; the meaning (⑦ remains the mechanism for taking an edit away that another set grants) is recoverable. A docs-only follow-up PR on the ADR is owed; nothing is pushed onto the head, which sits in the merge queue.
    • PR feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) #25980 body corrected through the relay (issue_patch, run 40293077750, read-back identical): the "Not measured here" bullet now states the above, and a "Follow-ups owed after contract review" section lists both items.
    • Accounting after this addendum: api_writes 7, all through the relay (POST pulls ×1, POST issues/25980/assignees ×1, PATCH issues/25980 ×3, POST issues/25866/comments ×2); mcp_calls 9, all reads (one more mcp__github__issue_read get_comments on feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) #25980 to read the review); GitHub MCP writes 0.
  9. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Landed in part · reopened · domain:spec seat 2

    reading value
    PR #25980 merged 2026-10-26T08:51:02Z by os-zhuang, merge commit c8cb510a5e65d25671c64061906b0954abf1fb4c
    origin/main 3518664cb, contains the merge commit (git merge-base --is-ancestor)

    State: reopened. #25980's Fixes #25866 closed the card, but ruling 6408558941 ① also requires that "the Studio permission-set form offers the key", and #25980 did not deliver that (seat verdict 6411666422). The same card thread delivers the remainder in PR #26010 (Part of #25866, branch claude/project-thread-xnz0xa, as the Claim names). The card stays pm:dispatched and closes when #26010 lands.

  10. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    Posted: 2026-10-26T09:33Z

    Patch round of #25980 (seat verdict 6411666422) — PR #26010, head 9bfef5bb5329f8a65d7c1283f96bc9279283c544, Part of #25866. Report 6411406490 and addendum 6411685596 stand for #25980; this one covers #26010.

    {
     "report": "os-dev-report",
     "round": "patch round of #25980 (seat verdict 6411666422) — supersedes nothing: report 6411406490 + addendum 6411685596 stand for #25980",
     "card": "objectstack-ai/objectstack#25866",
     "claim": "6410022810 (seat-amended to include the Studio form)",
     "ruling_ref": "6408558941",
     "branch": "claude/project-thread-xnz0xa",
     "pr": "objectstack-ai/objectstack#26010 (draft, Part of #25866, assignee zhuangjianguo, via relay pr_create)",
     "head_sha": "9bfef5bb5329f8a65d7c1283f96bc9279283c544",
     "commits_beyond_main": [
      "7e5e61a86 feat(ui,docs): the Studio permission-set form offers the field permission `when` (#25866) — cherry-pick of b38265727 onto main",
      "51f22e276 merge: brings the already-pushed pre-merge history (b38265727 and the #25980 commits) under the rebuilt branch; no content beyond main + the patch round",
      "9bfef5bb5 chore(changeset): the Studio form declared in its own changeset; the merged changeset of #25980 restored byte-for-byte"
     ],
     "verdict": "PASS — the three patch items delivered; gates 75/75 exit 0 after the spec rebuild, --ran 75/75/0/0; CI on head 9bfef5bb5 green (CI run 40296375639: 32 success / 4 skipped; Lint & Type Check run 40296375669: 14/14 success; PR Automation 40296375993 success; all guards success)",
     "declarations": {
      "Clause-②": "yes (widening)",
      "Metadata-protocol": "none (no schema change; the key landed in #25980)",
      "Ruling-ref": "6408558941",
      "changeset": ".changeset/25866-studio-field-permission-when.md — @object-ui/app-shell patch (ui/ fixed group); the merged .changeset/25866-field-permission-row-condition.md is restored to main's bytes (a changeset present at the merge base is not this change's to edit — check-changeset-overwrite)"
     },
     "landing": {
      "tier": "H — docs/adr/0066-unified-authorization-model.md touched (one sentence of the ⑧ note); contract review on the head, then os-zhuang or hotlong approval; PR stays draft",
      "card": "#25866 stays pm:dispatched on this branch (reopened by the seat, 6412200901); the seat closes it by hand when #26010 lands; no closing keyword in the PR body"
     },
     "what_landed": {
      "ui_PermissionMatrixEditor.tsx": "FieldsSubTable gains a 4th column 'When (edit)': per field a toggle button (aria-label '{object}.{field} when', aria-expanded) showing the authored condition as a chip or 'Add condition'; open → a CelPredicateField (id perm-when-{object}-{field}, placeholder status == 'draft', objectName + fieldNames for completion, the RLS lint with onLintChange) and a tip. Read off → the cell shows an em dash titled 'A row condition needs Read on.' and updateFieldPerm deletes `when` on the readable-off cascade. updateFieldWhen: blank removes the key, non-blank sets it and turns readable back on. bulkSetFields: 'readable'/'writable' go through withFieldBits(cur, readable, editable), which carries an existing `when`; 'clear' drops the entry. Lint errors on a non-blank `when` count into celErrorTotal, which holds Save and the autosave (perm-autosave-held) exactly like an RLS clause error.",
      "ui_permission-slice.ts": "FieldPerm gains `when?: string` (docblock cites objectstack#25866: edit vote only, same CEL as RLS using).",
      "ui_i18n.ts": "6 en + 6 zh rows: perm.field.when, .label, .add, .edit, .readOff, .tip.",
      "ui_test": "PermissionMatrixEditor.fieldWhen-25866.test.tsx — 6 tests on the package door with the autosave harness: offer/show/save; stored condition on load; Read off clears to {readable:false, editable:false}; Edit toggle + Write all/Read all keep, Clear drops; blank removes the key; a parse fault (status ==) holds the save with 'Fix the CEL syntax errors before saving.' and a fix releases it.",
      "adr": "ADR-0066 ⑧ note, last sentence now: 'Taking an edit away that another set grants remains the job of the muting/deny layer ⑦ still owes.'",
      "docs": "content/docs/permissions/field-level-security.mdx — one 'In Studio.' bullet (where the condition is offered, same lint, Read off clears, Read all / Write all keep).",
      "pr_25980_body": "patched via relay issue_patch (read-back identical, 11637 bytes): 'byte-identical' softened for the evaluator; the 'Not measured here' Studio paragraph now points at #26010; 'Follow-ups owed…' replaced by 'Patch round (seat verdict 6411666422) — landed as PR #26010'."
     },
     "file_surface_vs_claim": {
      "within_claim": [
       "ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx",
       "ui/packages/app-shell/src/views/metadata-admin/permission-slice.ts",
       "ui/packages/app-shell/src/views/metadata-admin/i18n.ts",
       "ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldWhen-25866.test.tsx (new)",
       "docs/adr/0066-unified-authorization-model.md",
       "content/docs/permissions/field-level-security.mdx",
       ".changeset/25866-studio-field-permission-when.md (new)"
      ],
      "deviations": [
       "none against the amended claim surface. Landing-point notes: (1) the branch is rebuilt from main (#25980 merged at 08:51:02Z while the patch was being written; the merge deleted the branch and the patch push recreated it from the old base) — a merge commit keeps the pushed history reachable because a force-push is not available to this seat; (2) the changeset split described under declarations."
      ]
     },
     "not_measured": [
      "the browser-rendered Studio: the 6 tests are vitest/jsdom; CI's Build & E2E UI lane is green on the head but has no scenario for the permission matrix `when` column",
      "`os explain` output: no code of this PR touches it; the explain wording landed in #25980"
     ],
     "tests": {
      "record": [
       "ui-vitest-when :: exit 0 :: cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldWhen-25866.test.tsx (6/6)",
       "ui-vitest-neighbours :: exit 0 :: cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin (43/43; incl. permission-slice.authoredKeys.test.ts 12/12)",
       "ui-typecheck :: exit 0 :: app-shell type-check incl. the test tsconfig (two runs, the second with the new test file)",
       "ui-eslint :: exit 0 :: 0 errors, 20 pre-existing warnings"
      ],
      "note": "permission-slice.authoredKeys.test.ts went red once: the bulk rewrite used a spread-first literal inside setDraft, which the key census counts as a draft write of its top-level keys; the rewrite moved into the withFieldBits helper (no spread-first literal) and the census is green again."
     },
     "gates": {
      "derive": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at 9bfef5bb5 → 75 commands (change set 7 paths vs merge base c8cb510a5)",
      "run": "each command verbatim under OS_VERIFY_LOCK_SLOT=issue-25866 scripts/pm/os-verify-lock.sh, exit code captured before any pipe (scratchpad run-record-4 + rerun-record-4)",
      "first_pass": "73 exit 0; pnpm --filter @objectstack/spec run check:docs :: exit 1 and pnpm --filter @objectstack/spec run check:skill-examples :: exit 3 — both PREREQUISITE refusals (gitignored packages/spec/json-schema and dist predate src after the rebase onto main; this diff touches neither) — not findings",
      "rerun": "pnpm --filter @objectstack/spec build :: exit 0 → check:docs :: exit 0 (229 generated files in sync) · check:skill-examples :: exit 0 (262 prose examples type-check) → final record 75/75 exit 0",
      "reconciliation": "node scripts/pm/dispatch-gates.mjs --ran {final record} --repo objectstack-ai/objectstack :: exit 0 — Run reconciliation: 75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN"
     },
     "ci_check_runs_on_head_9bfef5bb5": {
      "read_at": "at the time of this post, after the check_suite.completed event of 09:18:25Z reported nothing failed (actions_list list_workflow_runs + list_workflow_jobs)",
      "CI_run_40296375639": "success — Test UI 1–16/16, Lint UI, Type Check UI, Build & E2E UI, Build UI Deps, Test Core 1–8/8 + aggregator, Dogfood Regression Gate, Build Docs, filter; skipped (not path-scheduled): Build Core, Dogfood Verify CLI, Dogfood Regression Gate shards, Temporal Conformance",
      "Lint_and_Type_Check_run_40296375669": "success — all 14 lanes (Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI/release/engine gates, Lint · PM dispatch-gates, 3 scoped lint lanes, Type Check · workspace/source/consumer/debt ledger, TypeScript Type Check, Lint & Repo Gates)",
      "others": "PR Automation 40296375993 (Check Changeset 121483318058, Check PR Size, Auto Label) success; Governed Surface Guard, Spec Liveness Check, Check Links, Part-of Closing-Keyword Guard, Closing-Target Claim Guard, Single-Claim Path Guard, Duplicate Fix Guard success; Upgrade Harness / Pack Smoke (opt-in) skipped",
      "red_to_disregard": "PR Automation run 40296549618 (check_run 121483889962 'Check Changeset' failure, 09:02Z) ran on head 3d3775861 — #25980's merged head — with PR_NUMBER 25980 and merge base c8cb510a5 in its job env (most likely fired by the relay body patch of the merged PR); its diff against its own squash is empty, so 'adds no changeset' is expected there. No check on #26010's head is red."
     },
     "open_questions": [
      "1. The Studio lints the `when` text through rlsPredicateVerdict with clause 'check' (same compiler-backed pass; the check faces are the ones the in-process judge compileRowPredicate uses). The lint's messages therefore say CHECK, not WHEN. A dedicated 'when' clause in celAuthoring.ts is a small widening if the seat wants the wording to match the key — not done here to keep the surface inside the claim.",
      "2. Read off clears an authored condition with no undo (the patch order); the only recovery is retyping it. Acceptable for a draft form with autosave? If not, a confirm step is a follow-up.",
      "3. The merge commit 51f22e276 sits in the PR's history because a force-push is not available to this seat; a squash merge collapses it. If the maintainer wants a linear branch, say so and the seat can re-open from a fresh branch name."
     ],
     "flags": [
      "Tier H: ADR-0066 touched (one sentence of the ⑧ note) — contract review + maintainer approval owed, neither is this seat's",
      "PR body carries `Part of #25866`, no closing keyword; the card was reopened by the seat (6412200901) and stays pm:dispatched",
      "one stale red on the PR's checks tab belongs to #25980's merged head (see ci_check_runs_on_head_9bfef5bb5.red_to_disregard)",
      "no GitHub MCP writes; every write through the relay"
     ],
     "api_writes": {
      "count": 11,
      "all_through_relay": true,
      "endpoints": [
       "POST /repos/objectstack-ai/objectstack/pulls ×2 (pr_create → #25980, #26010)",
       "POST /repos/objectstack-ai/objectstack/issues/{n}/assignees ×2 (pr_create legs, zhuangjianguo)",
       "PATCH /repos/objectstack-ai/objectstack/issues/25980 ×4 (issue_patch body edits)",
       "POST /repos/objectstack-ai/objectstack/issues/25866/comments ×3 (post-stamped: 6411406490, 6411685596, this comment)"
      ]
     },
     "mcp_calls": {
      "count": 22,
      "github_writes": 0,
      "tools": [
       "mcp__github__issue_read ×6 (get ×2, get_comments ×4)",
       "mcp__github__pull_request_read ×6 (get ×1, get_check_runs ×4, get_status ×1)",
       "mcp__github__get_job_logs ×4",
       "mcp__github__actions_list ×3 (list_workflow_runs ×1, list_workflow_jobs ×2)",
       "subscribe_pr_activity ×3 (one refused for missing parameters; #25980, #26010 subscribed)"
      ],
      "method": "counted from the session transcript (every tool_use block named mcp__github__* or mcp__claude-code-remote__*)"
     },
     "thread": "stays open for the contract review's FAIL/patch rounds and PR events; worktree kept until #26010 merges or closes"
    }

    Notes outside the JSON:

    • Why a second PR. feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) #25980 merged (os-zhuang, 08:51:02Z, squash c8cb510a5) while the patch round was being written and the merge deleted the branch; the patch push recreated it from the old base. The branch was rebuilt from main + the patch commit, plus a merge commit that keeps the already-pushed history reachable (a force-push is not available to this seat). Content beyond main is exactly the 7 files above (+416/−32).
    • Gates. First pass 73/75 exit 0; the two refusals were prerequisite (stale gitignored spec build artefacts after the rebase), cleared by pnpm --filter @objectstack/spec build; final record 75/75 exit 0; --ran 75 derived / 75 run / 0 NOT-MEASURED / 0 UNRUN, exit 0.
    • CI. Everything on head 9bfef5bb5 is success or skipped-by-path. The one red on the checks tab (PR Automation run 40296549618, Check Changeset) ran on feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) #25980's merged head 3d3775861 with PR_NUMBER: 25980 — not this head.
  11. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    ACCEPT → PR #26010 comment 6412880222 (domain:spec seat 2); contract review PASS comment 6412858617 on head 9bfef5b. Tier H (docs/adr/**): waits for an APPROVED review from os-zhuang or hotlong on this head; the PR stays draft until then. This card is closed by hand when #26010 lands.

  12. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    Contributor

    Landed · domain:spec seat 2

    reading value
    PR #26010 merged 2026-10-26T15:16:10Z, merge commit 7111e14ac9c11606b8bad85151b3915f18f6b150 (after os-zhuang's APPROVED review 5653843747)
    origin/main 7111e14ac, contains the merge commit (git merge-base --is-ancestor)

    State: #26010 delivered this card's remainder (Part of, reopened for it in 6412200901), so the card is closed by hand as completed, with pm:dispatched removed in the same stroke.

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 guardsdomain:specenhancementNew feature or requestpriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions