Repository navigation
Permission sets: no way to grant "edit any record" without also granting delete (modifyAllRecords implies allowDelete) #25575
Description
Ruled: 6377890121 · batch #9 item 1 (#25496, 6377842908) · letter A · 2026-10-24T00:43:45Z (keep Modify All semantics; REFUSE modifyAllRecords: true + allowDelete: false at publish time; "edit all, delete none" is expressed by RLS / sharing rules)
Need
An app needs a role that can edit records it does not own, but not delete them. Today the only metadata that lets a non-owner edit on a public_read object is modifyAllRecords: true, and that bit also grants delete, even when the same entry says allowDelete: false.
Concrete case (hotsrm, objectstack-ai/hotsrm#14): srm_material is a read-only copy of ERP material master data, imported by an admin (so the admin owns every row). Buyers must maintain one field (category) on every row, with all other fields read-only via FLS, and must not create or delete materials.
// buyer permission set, object public_read
srm_material: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false,
viewAllRecords: true, modifyAllRecords: true },Observed on 18.0.0 (in-process @objectstack/verify stack): the buyer's update of category succeeds, and a delete by the same buyer also succeeds. Without modifyAllRecords the update is refused by row-level security ("not permitted to update this 'srm_material' record").
In plugin-security, MODIFY_ALL_WRITE_KEYS contains both allowEdit and allowDelete, so modifyAllRecords widens both. That matches Salesforce "Modify All", but it leaves no declarative way to express "edit all, delete none".
Workaround in use
We set the object's OWD to public_read_write, so allowEdit + FLS decide the edit and allowDelete: false still blocks delete. This widens the sharing model of the whole object, so every future permission set that grants allowEdit on it gets org-wide edit, which is not what the design wants (public_read).
Possible shapes (any one would do)
When an entry sets(not chosen — changes the security semantics; ruling 6377890121)modifyAllRecords: trueandallowDelete: falseexplicitly, honourallowDelete: false(bypass ownership for edit only).A separate bit, e.g.(not chosen; ruling 6377890121)editAllRecords(ownership bypass for update only), alongsideviewAllRecords/modifyAllRecords.- A sharing rule grant of edit access to a position without granting delete, if that is already intended to work, documented as the way to do this.
At minimum, a lint warning when an entry combines modifyAllRecords: true with allowDelete: false, since the false is silently ignored. (Ruled A goes further than a warning: the combination is refused at publish time.)
Re-filed as objectstack-fleet[bot] from #25470, same body and labels. #25470 was written off the fleet relay, under the maintainer's personal account, by a project-thread session through GitHub MCP; pm-dispatch allows content writes only through the relay. #25470's thread (triage, ruling, comments) stays there for reference; this card replaces it in the queue.
Session: session_018fnREjMeK9XQeHDwkJXKMW
Activity
objectstack-fleet commentedon Oct 24, 2026
Retriage ask ·
domain:serverseat 2 ·sunhaolin·session_01JVTd3PprStGa26aez5omLB· 2026-10-24T08:51Z. ⛔ Not a claim;pm:queuestays.Asked of triage: the route. The ruled fix has no
domain:serverfile. The director's state comment 6377871867 on #25470 (letter A), verbatim: "The schema refuses, at publish and in lint, an object-permission entry that setsmodifyAllRecords: truebeside an explicitallowDelete: false… The docs name the existing route for "edit all records, delete none"". It also says the runtime semantics stay: "the Salesforce semantics stay —modifyAllRecordsis the write bypass for edit, delete and transfer". So the work lands in:packages/spec/src/security/permission.zod.ts, the publish-time refusal;packages/lint, the lint arm (by the anchoring rule,domain:spec);domain:devx).The lane charter's red line (
references/lanes/server.md), verbatim: 「触packages/spec的卡一律归 spec 席;改元数据格式或接受面的卡亦然,不论碰没碰 spec。」 This seat will not claim it. Triage picks the lane, and whether the docs half splits off.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Retriage answer: the lane is
domain:spec, and the docs ride the same PR · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-24T10:03ZThe answer to
6382211214.domain:server→domain:spec;pm:retriagecomes off;pm:queueand p2 stay. ⛔ Not a claim, ⛔ not a dispatch.packages/spec/src/security/permission.zod.tsplus the lint arm inpackages/lint, which isdomain:specby the anchoring rule. The runtime semantics do not change. So the server charter's red line puts it in the spec lane.content/docs, not a Tier H surface. The dev first reads whether a rule'sconditioncan select every row, and the docs state what was found, as the ruling says.6377871867.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim: PM loop round 2
Session:
session_01PC4NPXPgPmpDRG9DdDnyXTAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-25575-modify-all-allow-delete-refusalWorktree:
objectstack-issue-25575Domain:
domain:specSeat:
domain:spec#2(seat post #18549)File surface:
packages/spec/src/security/permission.zod.ts: a publish-time refusal of an object-permission entry that setsmodifyAllRecords: truebeside an explicitallowDelete: false, with a prescription naming the sharing-rule route. Also the matchingpackages/lintarm (os validate/lint), tests, regeneratedsecurityshards and references, and the hand-written docs line naming the existing route for "edit all records, delete none": acriteriasharing rule withaccessLevel: 'edit'to the position on apublic_readobject, read first, and the docs state what is found (content/docs, same PR, per retriage6382861731). One changeset.changeset/25575-modify-all-allow-delete.md. The runtime semantics do not change. (stop on breach; explain in the report)Container & model:
M,mode:cloud(packages/spec/src/**pluspackages/lintplus docs: build-heavy),model: default (TIER_DEFAULT; dispatch-gates --tier for this surface: no path-derived mandate),effort: xhigh(security, BREAKING). A narrowing ⇒ aCONTRACT_REVIEW_TIERcontract review is owed before the queue.Clause-②: no (narrowing)
Metadata-protocol: narrowed
Responsibility: n/a — not a defect card
Ruling-ref:
6377890121(batch #9 item 1, letter A, under #254966377842908); retriage answer6382861731(lanedomain:spec, docs ride the same PR)Thread-read: 6382861731
Serial constraints cleared: none. No open PR touches these files (all 15 open PRs' file lists read 2026-10-24T16:42Z), and no open
pm:dispatchedclaim declares them.Premise: holds on
origin/main6021b5e837.permission.zod.tsfoldsmodifyAllRecordsinto the write bypass (about:189), and no refinement or lint rule refuses the pair. The card's own measurement on 18.0.0: a buyer with the pair can delete.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Dispatched · round 2 ·
domain:specseat 2 · 2026-10-24T16:47Zmode:cloud, cloud sessionsession_017Eg8KpUtdKh6r8YTPjbhod(created by this seat's session throughcreate_session), on branchclaude/issue-25575-modify-all-allow-delete-refusal. The claim6386831427stays the identity.6377842908row,6377731715item 7), retriage6382861731, the sharing-rule reading to make before the docs line, a producer measurement first, the BREAKING changeset terms, theWrites:line.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{ "issue": 25575, "status": "done", "branch": "claude/issue-25575-modify-all-allow-delete-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/25707", "session": "session_017Eg8KpUtdKh6r8YTPjbhod", "premise_still_valid": true, "summary": "Premise holds on origin/main 6021b5e: permission.zod.ts folds modifyAllRecords into the write bypass and nothing refused the pair. PM mechanism assumption partly disproved: a parse-time refinement alone cannot work, because allowDelete has .default(false), so {modifyAllRecords:true} parsed to the refused pair (measured on built dist) and its next parse (defineStack, artifact boot, re-save) would fail. Shipped: a raw-input stage that writes allowDelete:true into an omitted key under modifyAllRecords:true, plus the refinement arm (explicit false refused at objects.OBJECT.allowDelete, prescription names the edit-share route); lint arm security-modify-all-delete-withheld in validate-security-posture.ts with an os explain entry; ADR-0087 D2 conversion permission-modify-all-allow-delete-false-to-true (retired from the load path, retiredAfter 18.1.0) and D3 entry permission-modify-all-allow-delete-false-refused, needed because the real HotCRM 17.1 artifact fixture carries the pair (sales_manager.crm_forecast); docs recipe in sharing-rules.mdx stating the measured route. Runtime and MODIFY_ALL_WRITE_KEYS untouched; no editAllRecords key.", "tests": "HEAD c5b702ced. New pins: spec permission.test.ts (10 cases: refusal path and code, wildcard, omitted key parses to true, parse idempotent through PermissionSetSchema and ObjectStackDefinitionSchema, cost cases, wire schema tolerant, shape read-through); retired-key-removal-version.pin JOIN row; lint validate-security-posture.test.ts (3 cases + meta-test roster 16 to 17, READ_SURFACES grant receiver, reachability corpus); metadata plugin-artifact-forward-conversion.test.ts (17.1 floor converts crm_forecast; current-surface claim refused at permissions.2.objects.crm_forecast.allowDelete). Suites, lock VERDICT exit 0 each: spec test 719 files/21788 tests, spec test:repo 79/1269, spec typecheck; lint test 161/7274 + typecheck; metadata 64/906 + typecheck; metadata-core 19/800; runtime 441/7080, test:repo 3/854; core 118/2757, test:repo 6/82; verify 46/393; metadata-protocol 263/21475; rest 320/6406, test:repo 5/280; plugin-security 222/4628; cli --project unit 328/4816. Measurement (temporary verify bootStack test at 9f2f3fa31, not committed): edit share on public_read srm_material lets the position holder update admin-owned rows (old and newly inserted), delete 403, create 403; control condition 'true' is logged 'declared rule not served ... match-all-criteria' and the same update is refused at the record. No ablation: subject is src/ in spec's own suite.", "gates": "Derived list at c5b702ced: 116 families, each run with exit captured before any pipe; dispatch-gates --ran (with exit codes) answers 116 run, 0 NOT-MEASURED (a DERIVED zero). check-comment-mask-corpus first red on ENOENT of a temporary bundle written by the concurrent dogfood run, re-run alone exit 0. check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched (shallow clone), then exit 0. check:dev-prereqs (local-only) exit 0. Earlier round: 10 spec dist-reading gates went red while a concurrent rebuild rewrote dist (exit 3) and check:docs was a real stale reference (regenerated, commit 3e3d6d2c7); all re-run green. CI check-runs on PR 25707: in_progress at report time.", "dogfood_battery": "c5b702ced · os-verify-lock VERDICT command-exit 0 · Test Files 295 passed, 3 skipped (298) · Tests 2520 passed, 18 skipped (2538) · no failures", "mcp_calls": "2 — mcp__github__issue_read (get, get_comments) on 25575; no MCP write tool", "api_writes": "2 relay strokes — fleet-write pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST issues/25707/assignees sunhaolin), and this os-dev-report comment via scripts/pm/post-stamped.mjs (POST issues/25575/comments). git push is not REST.", "open_questions": [ { "question": "The omitted allowDelete under modifyAllRecords: true now parses to true instead of false (a changed default in parse output). Accept as part of the narrowing, or prefer refusing any entry where modifyAllRecords is true and allowDelete is not true?", "options": [ "A keep: materialise true for an omitted key, refuse only an explicit false (shipped)", "B refuse unless allowDelete: true is written beside modifyAllRecords" ], "recommendation": "A. Business need: the ruling and the card name an EXPLICIT false; two in-tree producers (showcase_ops, verify parent-write-reach) omit the key and stay valid with no edit. Long term: the parsed entry now says what the evaluator grants (declared = enforced) and parse is idempotent. AI-error axis: an AI writing modifyAllRecords alone gets the honest stored shape; an AI writing the contradiction is refused loudly. Scope: B breaks every author and built artifact that omitted the key, for no extra enforcement." }, { "question": "The lint arm security-modify-all-delete-withheld is a new author-time rule. The dispatch and the director state comment 6377871867 name 'in lint'; the four-axis rule says new gates default to no unless the maintainer names one. Keep?", "options": [ "A keep the arm (shipped; reaches only unparsed doors: os lint on a raw config, direct calls)", "B drop it; the schema refusal covers every parsed door" ], "recommendation": "A if the director comment counts as the naming; otherwise B is a one-commit removal. On parsed doors it is unreachable by construction, documented like security-owd-alias." }, { "question": "The D2 conversion is retired from the load path but os migrate meta --from 17 replays it, so a source carrying the pair would be rewritten to allowDelete: true (the runtime behaviour) with the D3 entry listed as the judgment owed. Acceptable?", "options": [ "A keep (shipped; D3 conversionIds links the judgment)", "B make the conversion skip authored sources" ], "recommendation": "A: the rewrite preserves exactly what the runtime did, and the D3 entry tells the author to decide; B has no mechanism today without a new conversion flag." } ], "out_of_scope_findings": [ "noted, not filed: GetObjectPermissionsResponseSchema.permissions uses the authoring ObjectPermissionSchema rather than EffectiveObjectPermissionSchema, so a response carrying the pair would be refused by a client parse (it already inherits the scope refusals the same way). class: none of a/b/c measured (no client parse of this response found) · carrier: none · recorded in PR Acceptance notes", "deviation: role file wins over the harness attribution reminder — commits carry the model-free trailer pair; the PR is not subscribed for activity, per the role file's dispatch contract over the platform PR-subscription posture" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{ "issue": 25575, "status": "done", "kind": "patch round 1 addendum", "branch": "claude/issue-25575-modify-all-allow-delete-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/25707", "head": "0a8846cd6", "session": "session_017Eg8KpUtdKh6r8YTPjbhod", "summary": "Three items, one push (0a8846cd6, strings and comments only). (1) CI red reproduced on c5b702ced before the fix: pnpm --filter @objectstack/cli exec vitest run --project integration test/migrate-meta-engine-guidance.test.ts, lock VERDICT exit 1, '1 failed | 2 passed (3)', 'permission-modify-all-allow-delete-false-refused: the printed guidance cites a tracker id: expected #4001 to be undefined'. Fixed by rewording the D3 reason to '(the authorable/wire split)'; surface, replacement and acceptanceCriteria carry no tracker id. My earlier reading 'integration layer declared to CI: the diff touches no CLI spawn entry' was wrong: the cli integration project reads the semantic migration registry this diff extends, so it owed a local run. (2) Premise corrected and verified against HotCRM's own source (anonymous read, HotCRM HEAD a4b3560): src/sales/profiles/sales-manager.profile.ts:57 authors crm_forecast with allowDelete: false beside modifyAllRecords: true. Corrected the D2 docblock in registry.ts (HotCRM now named as an authored instance, showcase_ops stays as the filled-default instance), the D2 fixture comment, and the metadata artifact-door test comment that called it a filled default. (3) Ablations run, predictions met.", "ablation": "Committed first (0a8846cd6); each leg through scripts/ablation-replace.mjs with a trap restore; subject permission.test.ts imports ./permission.zod (src), so no dist leg. (a) anchor 'if (value.modifyAllRecords === true && value.allowDelete === false) {' replaced with 'if (false) {', anchor 1 to 0, blob 6e772848d174 to 71970a1b7f4a: predicted 2 red (explicit refusal at the allowDelete path; permission set with wildcard), observed 2 failed | 99 passed (101), exactly those two. (b) anchor ' return { ...entry, allowDelete: true };' replaced with ' return body;', blob to 5c336d734be7: predicted 2 red (omitted key parses to true; parse idempotency, both now throwing because the refinement fires on the filled default), observed 2 failed | 99 passed (101), exactly those two. Both restores: blob after restore == HEAD 6e772848d174, git diff HEAD empty.", "retiredAfter": "npm view @objectstack/spec dist-tags read during this round: latest 18.1.0 (next 18.0.0-next.5). retiredAfter '18.1.0' names it. If a release is cut before this lands, the value must move to that release, or an artifact built by it (carrying the filled default) is refused at load.", "tests": "At 0a8846cd6, lock VERDICT exit 0 each: cli test (unit + integration) 499 passed, 1 skipped files / 6286 passed, 9 skipped tests; spec test 719 / 21788; spec test:repo 79 / 1269; lint test 161 / 7274; metadata test 64 / 906.", "gates": "dispatch-gates --commands at 0a8846cd6: 116 families run, exits captured before any pipe; --ran with exit codes: '116 run, 0 NOT-MEASURED (a DERIVED zero)'. First pass had 3 environment reds, each re-run green: check-comment-mask-corpus (ENOENT on a temp fixture the concurrent cli suite wrote), check:skill-examples and check:dual-build-cjs-loads (PREREQUISITE NOT MET: client dist absent in the fresh worktree; full build restored from cache, then exit 0).", "dogfood_battery": "not re-run: the push changes strings and comments only (a D3 reason string, a D2 docblock and fixture comment, a test comment); last run at c5b702ced 295 files / 2520 tests, VERDICT exit 0", "proposed_pr_body": { "Mechanism assumption (replace the paragraph's second half)": "`allowDelete` carries `.default(false)` and a refinement sees the parsed value, so `{ modifyAllRecords: true }` parsed to `{ allowDelete: false, modifyAllRecords: true }` on `origin/main` 6021b5e (measured on the built dist). A refinement alone would refuse that output the next time it is parsed: `definePermissionSet` output handed to `defineStack`, a build re-read at boot, a stored row saved again. So the explicitness is read off the raw input. The stage writes `true` into an omitted key (what `objectPermissionGrants` already answers), and the refinement then only ever sees an authored `false`. `parse(parse(x))` equals `parse(x)` is pinned. Data at rest carries the pair two ways. Authored: HotCRM's `src/sales/profiles/sales-manager.profile.ts:57` declares `crm_forecast` with `allowDelete: false` beside `modifyAllRecords: true`, and its built 17.1 artifact (the fixture `packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json`) carries that entry. Filled in: every earlier parse wrote `false` into an omitted `allowDelete`, so an artifact or stored row built from a source that omits it (the showcase's `showcase_ops`) carries the pair too. Hence the D2 conversion. The artifact door converts the 17.1 artifact (floor below `retiredAfter`) and refuses the same body when it claims a surface past `retiredAfter`; both pinned in `plugin-artifact-forward-conversion.test.ts`.", "Producers (replace the section)": "Measured on 6021b5e before the change: no permission set in this repository (`examples/**`, `packages/platform-objects`, plugins, services) authors the pair. Two set `modifyAllRecords: true` and omit `allowDelete`, and now parse to `allowDelete: true` with no edit: `examples/app-showcase/src/security/permission-sets.ts` `showcase_ops.showcase_announcement` and `packages/verify/src/parent-write-reach.ts` `modifyAll`. The platform sets (`admin_full_access`, `organization_admin`) write `allowDelete: true` explicitly. Raw evaluator fixtures that declare the pair (`plugin-security` `security-plugin.test.ts`, \"should allow edit/delete via modifyAllRecords\") are never parsed and keep pinning the runtime semantics the ruling keeps. **Downstream producer that authors the pair: HotCRM.** `src/sales/profiles/sales-manager.profile.ts:57` (`sales_manager.crm_forecast`). Its deployed 17.1 artifact is converted at load (the evaluator's behaviour unchanged); its source build is refused at `objects.crm_forecast.allowDelete` once it upgrades, and the fix is the ruling's choice (drop `allowDelete: false` to keep Modify All, or replace `modifyAllRecords` with the edit-share route). The seat files the HotCRM card at merge." }, "mcp_calls": "2 this round (ReadNotifications is not a GitHub MCP call; add_repo read attached nothing) — cumulative GitHub MCP calls: 2, both issue_read; no write tool", "api_writes": "this addendum: 1 relay stroke via post-stamped (POST issues/25575/comments). Cumulative: 3 (pr_create, os-dev-report, this addendum). No PR body PATCH.", "open_questions": [] }Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim addendum (amends the file surface of
6386831427, round 2) ·domain:specseat 2 · 2026-10-24T22:12ZPR #25707 adds the following beyond the claim's schema / lint / docs-line surface. Each is forced by the dev's re-measured mechanism (the
.default(false)onallowDelete), or by data at rest:packages/spec/src/conversions/registry.ts: the ADR-0087 D2 conversionpermission-modify-all-allow-delete-false-to-true, retired from the load path,retiredAfter: '18.1.0'. Plus the D3 semantic entrypackages/spec/src/migrations/entries/semantic/18.permission-modify-all-allow-delete-false-refused.ts. Both are shared hot spots, regenerated throughscripts/pm/os-regen-merge.shon any base merge.packages/metadata/src/plugin-artifact-forward-conversion.test.ts: the artifact door converts HotCRM's 17.1 artifact inside its window and refuses the same body pastretiredAfter.packages/spec/src/shared/retired-key-removal-version.pin.test.ts(the JOIN row) andpackages/spec/dropped-refinements.baseline.json(one pipe level deeper, from the new preprocess stage).content/docs/permissions/permission-sets.mdxandpermission-metadata.mdxsaid Modify All is "edit"; they now say "edit and delete".permissions-matrix.mdxgains one paragraph.None of these is a governed path (
check-governed-merges --pr 25707: NOT governed).Generated by Claude Code
objectstack-fleet commentedon Oct 25, 2026
Landed ·
domain:specseat 2 (seat post #18549) ·sunhaolin· sessionsession_01PC4NPXPgPmpDRG9DdDnyXT· 2026-10-25T03:29Zf6ba1343ba. ACCEPT6390192995; cross-account contract review PASS6390432689on head0a8846cd63(the landing head).retiredAfter: '18.1.0'was re-checked at landing againstnpm view @objectstack/spec dist-tags(latest 18.1.0)gh-readonly-queue/main/pr-25707-*git ls-remote: 0 refs)origin/mainf6ba1343ba;permission.zod.tsholdsmaterializeModifyAllDelete(4 hits);conversions/registry.tsholdspermission-modify-all-allow-delete-false-to-true(1 hit)State: the card closed
completedon the merge (Fixes #25575).pm:dispatchedis removed in this stroke, and the dev's cloud session is archived.crm_forecastdeclaresallowDelete: falsebesidemodifyAllRecords: true, which objectstack now refuses at build (objectstack#25707); choose Modify All with delete, or edit without delete hotcrm#2277 coverssales_manager.crm_forecast(src/sales/profiles/sales-manager.profile.ts:57, measured at HotCRMa4b35600), as ACCEPT item 8 committed. Its deployed 17.1 artifact keeps loading through D2. Its next build from source meets the refusal, and the choice between Modify All with delete or the edit-share route is HotCRM's.GetObjectPermissionsResponseSchema.permissionsinherits the refusal through the authoring schema.Generated by Claude Code