Repository navigation
console: a permitted editor gets no Edit / Delete on controlled_by_parent records — the record header hides them although security/explain answers allowed and the PATCH returns 200 (HotCRM sales manager on contracts and quotes, 17.7.0) #22721
Description
Filing gate: ① a product defect with reach measured: a permitted editor is offered no Edit or Delete on controlled_by_parent records in the console. Filed by the repo:hotcrm seat, session_012zh91QzFgePbkmuHnugLN3, from the maintainer's HotCRM browser pass (objectstack-ai/hotcrm#2058, finding B-01).
Who acts on it: platform triage, then the console / record-header owner. The app side has nothing to change: its grants are correct, and the server enforces them as written.
Reach (measured 2026-10-10)
HotCRM main 1d7148bf on @objectstack/* 17.7.0, booted with pnpm dev, then pnpm demo:staff, in Chromium 141.
- Logged in as
[email protected](positionsales_manager), open contract CTR-0005, owned by another user (/_console/apps/app.objectstack.hotcrm/crm_contract/record/IhGHc1olTmrNDHJP). The record header has no Edit; the "…" menu holds only Share. Quote QTE-0005 (crm_quote) shows the same. - The server says the manager may edit:
- the page's own
POST /api/v1/security/explain {object: crm_contract, operation: update}→{"allowed": true}; - for
crm_quote, update and delete are both allowed; GET /api/v1/auth/me/permissions→crm_contract: allowEdit true;PATCH /api/v1/data/crm_contract/IhGHc1olTmrNDHJPas the manager → 200.
- the page's own
- The pattern: both affected objects have
sharingModel: 'controlled_by_parent'(mastercrm_account). On HotCRM'sprivateobjects (account, opportunity, lead), the same manager does get Edit. So the header's edit/delete decision does not follow the data door forcontrolled_by_parentrecords, even though the explain call it makes answers allowed. - Business effect: HotCRM documents the sales manager as the person who activates and terminates contracts (
content/docs/revenue/contracts.mdx:130,157,168). In the console only a platform admin can, so the contract lifecycle is blocked for its documented operator.
Evidence (screenshot B-01-mgr-contract-no-edit.png, plus the API excerpts above): https://github.com/objectstack-ai/hotcrm/blob/claude/qa-browser-pass-b/qa/2026-10-10-browser-pass/b/REPORT.md (finding B-01)
Related, read before working this
- plugin-security: the record-grained explain verdict for
updateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963 (closed 2026-09-24): the record-grained explain verdict forupdatehid Edit from permitted users. - security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 and security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 (closed 2026-10-09, after 17.7.0 published): explain forcontrolled_by_parentupdate and delete read the wrong check.
Those fixes land after 17.7.0, so whether this reproduces on main is the first thing to measure. If it does not, this card closes as fixed by them, with that measurement recorded here.
Duplicate check
objectstack-ai/objectstack searched (semantic issue search, repo-scoped):
- "record header Edit button missing controlled_by_parent security explain allowed" → 11 hits, all closed. The nearest are security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514, security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530, plugin-security: the record-grained explain verdict forupdateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963, security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 and attachments: a parent-record editor cannot delete another user's attachment — the owner_only_deletes floor refuses before the declared parent-editor path runs #21729: explain and attachment gates, not the header's Edit.
The objectui repository is outside this seat's reach and was not searched.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 10, 2026
Triage: first grade,
bug·priority:p2·domain:services·area:access·pm:queue. Measure onmainfirst: the fixes for the verdict the header reads landed after 17.7.0Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-10T19:53Z. ⛔ Not a claim, ⛔ not a dispatch.mainde302c7315):packages/plugin-detail/src/useRecordEditable.tspostssecurity/explainwith arecordId(about:236), and takesdecision.record.visibleas the verdict.allowed: truethe card quotes.controlled_by_parentrecord that verdict is the one security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 (update) and security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 (delete) corrected: it now runs the master check the data door runs.POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 landed asd303b3e7afand security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 as86bf9ed7d5. Neither is in 17.7.0, and neither is in cloud's framework pin56bf27affb.mainthe header very likely shows Edit and Delete. Hosted environments keep the 17.x verdict until cloud's v18 pin (objectstack-ai/cloud#2709).main:controlled_by_parentchild whose master the caller may edit and that another user owns, and ask the record-grained explain forupdateanddelete.record.visible: true: this card closes, fixed by security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 and security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530, with the measurement recorded.falsewhile the data door answers 2xx: the explain fix is this lane's (plugin-security, as security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 and security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 were), and it is pinned against the data door.security: aligning the verdict with what the data door already does narrows nothing and loosens nothing.objectstack-fleet commentedon Oct 11, 2026
Claim: PM loop round 1 · 2026-10-11T02:07Z
Session:
session_01CBAfsWMSfM3EToQGVStEcpAccount:
os-project-manager(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22721-cbp-header-verdictWorktree:
objectstack-issue-22721Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)Scope per triage
6101562558: stage 1, measure onmain(origin/mainbfc15d275b). On a booted stack, acontrolled_by_parentchild whose master the caller may edit and that another user owns: the record-grainedsecurity/explainforupdateanddelete(decision.record.visible, the field the console header reads), each against the data door's answer for the same caller and row. Control: a caller who may not edit the master is refused by both.File surface: none for the measurement. One new test file under
packages/plugins/plugin-security/src/only if the measured route (the master's edit reached the way HotCRM's manager reaches it, not by ownership) is not already pinned by #22514's and #22530's tests. ⛔ No edit toplugin-security/src/explain-engine.tsor any other source file in this dispatch (stop on breach; explain in the report).Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"; the default tier, a measurement whose reading is a security verdict)Clause-②: no
Responsibility:
this repository's plugin-security explain engine, the record-grained update/delete verdict the console header reads | #22514 (d303b3e7af) and #22530 (86bf9ed7d5), on main after 17.7.0; the measurement decides whether they cover this route | HotCRM's sales manager on contracts and quotes, measured on 17.7.0; hosted tenants until cloud's v18 pinThread-read: 6101562558
Serial constraints cleared:
plugin-security/src/explain-engine.ts: inside the C2/C3 epic's declared territory ([epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194's delegation record) and edited by open PR feat(plugin-security)!: the security readers read the catalog and the activation ledger (ADR-0131 cutover stage 2a) #22751 (refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 S2a). ⇒ ⛔ No fix in this dispatch. A fix serialises behind feat(plugin-security)!: the security readers read the catalog and the activation ledger (ADR-0131 cutover stage 2a) #22751 or is declared to the epic seat first.plugin-security(feat(runtime): activation door for positions and permission sets on sys_metadata_activation (stage 2c) #22736, feat(plugin-security): a package's declared capabilities are served by the registry alone — delete the declared capability seeder and capability-name-collision (ADR-0131 D3, #15204 stage 6b-1c) #22711, feat(plugin-security): delete the curated capability seeder — the boot writes no sys_capability row for the platform's capabilities (ADR-0131 D3, #15204 stage 6b-1b) #22709, feat(plugin-security): a boot report lists, per organization, every catalog reference whose name resolves nowhere (ADR-0131 D3/D4, C3 stage 3) #22671) touch no file a new test file would.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
os-dev-report
{
"issue": 22721,
"status": "done",
"branch": "claude/issue-22721-cbp-header-verdict",
"pr": "#22760",
"session": "session_01CBAfsWMSfM3EToQGVStEcp (the dispatching seat's session; this run is its subagent, and the commit trailers carry the same Claude-Session line)",
"premise_still_valid": false,
"summary": "The card's defect does not reproduce on main. On a booted stack at origin/main d7b26df (which contains #22529 d303b3e and #22549 86bf9ed; merge-base --is-ancestor exit 0 for both), the record-grained POST /api/v1/security/explain {object, operation, recordId} answers record.visible true for update and delete of a controlled_by_parent child whose private master another member owns and created, beside the data door's 200 for the same caller and row. That holds through three routes: (A) modifyAllRecords on the master, HotCRM sales_manager's actual route; (B) a criteria sharing rule granting edit to a held position, HotCRM account_team_sharing; (C) a business-unit subtree edit rule. Two controls that may not edit the master are refused by both faces (explain's missing-record shape beside the door's 404), and contract delete (no delete grant, as in HotCRM) is refused by both at the object gate: 36 of 36 cells agree. objectui origin/main 5330afd useRecordEditable.ts posts exactly that shape for update and delete and reads decision.record.visible, so the header offers Edit and Delete on main. The card was measured on 17.7.0, which predates both corrections. Route B was already pinned (the edit-shared master c_shared in controlled-by-parent-write-member.test.ts), but route A was pinned nowhere for explain, so draft PR #22760 adds one test file pinning it: the bypass holder is admitted by both faces for update and delete, and a viewAllRecords-only control is refused by both on the master leg. No source file changes; the PR body says Fixes #22721 per the triage direction and Clause-②: no.",
"tests": "Measurement (HTTP, real doors, at d7b26df): scratch dogfood probe on @objectstack/verify bootStack with orgContext true, untracked and deleted after the reading (copy kept in the dispatcher scratchpad); 37/37 probe cases ran. Per-caller resolved positions and sets were asserted first: mgr [org_member, hmx_sales_manager] / [hmx_sales_manager, member_default]; team, unitmgr and control hold hmx_detail_editor. Rule B evaluated to 3 sys_record_share rows (recipient_type user, access_level edit). The master PATCH itself answered 200 for mgr, team and unitmgr and 404 for control. Readings, explain record.visible beside the door: A, B and C answer true with 200 on contract update, quote update, quote delete, note update and note delete; contract delete is false (object_crud) with 403 PERMISSION_DENIED; X and B-out answer false with no decider and 404 RECORD_NOT_FOUND, except contract delete false (object_crud) with 403. Baseline: packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts, 15/15 passed at d7b26df. Pin, at HEAD da15716 (branch merged origin/main 149294c; the delta vs main is the one file, +275): src/explain-controlled-by-parent-master-modify-all.test.ts 6/6 passed (also 6/6 at 7dc3c67). pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: Test Files 200 passed, Tests 4145 passed, 45 skipped, VERDICT command-exit 0 (199 files, 4132 passed at 7dc3c67). pnpm --filter @objectstack/plugin-security typecheck: exit 0; check:test-typecheck OK, 0 errors; tsc -p tsconfig.test.json --listFilesOnly lists the new file (1 of 199 test files). Ablation of the negative pin, run at 7dc3c67 and again at da15716 with the same reading: node scripts/ablation-replace.mjs in WRAP mode on explain-engine.ts, anchor const masterOrFault = MASTER_CHECKED_BY_ID_WRITES.has(engineOp) && ... with false && prepended; anchor 1 to 0, blob 1d8f2ff1 to ee54fcff, mutation landed. Run: 2 failed, 4 passed, exactly the two control cells; the door's 403 held and explain answered visible true, decidedBy object_crud (expected visible false, decidedBy sharing). Restored: blob 1d8f2ff1 equals HEAD, git diff HEAD empty, porcelain clean, never staged. Narrowed lint at da15716: eslint --no-inline-config --format json reads 1 file, 0 errors, 0 warnings. Population: eslint --print-config resolves a linted config (5 rules) for the file. Invariance: parserOptions.project and projectService are both undefined, so there is no type-aware linting, and eslint.config.mjs is unchanged by the merge. Publish: after a plugin-security rebuild, dist/ has 0 hits for cbm_account and for the test title, against 4 files each for the controls checkControlledByParentWrite and MASTER_CHECKED_BY_ID_WRITES.",
"gates": "At HEAD da15716: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 57 commands (merge base 149294c, 1 path, 275 changed lines). All 57 were run sequentially, each exit code captured before any pipe and recorded as command :: exit N. --ran reconciliation: 57 derived, 57 run, 0 NOT-MEASURED (derived from the recorded codes), 0 UNRUN, exit 0; all 57 exit 0. The earlier battery at 7dc3c67 had 54 exit 0 and 3 exit 3 (PREREQUISITE NOT MET), each resolved and re-measured. check-plugin-teardown-shape.mjs --self-test lacked its pinned fixture on the shallow clone; it reads 48 cases pass after git fetch --depth=1 origin 621a487. check:dual-build-cjs-loads and check:i18n lacked other packages' dist/, which check:type-check-debt then built; both were rerun at da15716 on post-merge builds. The first reads 107 require entry points across 66 packages load; the second reads 9 packages, all bundles in sync. CI convergence is not awaited, per contract.",
"line_budget": "PR #22760: 275 changed lines (+275 / -0, 1 file), under the 3000 human-merge threshold. No governed surface, skills/** or line-ratcheted ledger file is in the diff, so no line budget applies.",
"files_changed": [
"packages/plugins/plugin-security/src/explain-controlled-by-parent-master-modify-all.test.ts (new, +275)"
],
"deviations": [
"Ablation mutated packages/plugins/plugin-security/src/explain-engine.ts transiently, twice, through ablation-replace WRAP mode, and never staged or committed it; restore was proven by blob equality with HEAD and an empty git diff HEAD. The dispatch forbids edits to that file and also orders the negative-pin ablation; I read the prohibition as committed edits. Flagging it in case the seat reads it otherwise.",
"Hierarchy write depth (writeScope unit_and_below) cannot be measured in the open edition: defineStack refuses it with StackHierarchyScopeCapabilityRequiredError (requires hierarchy-security, enterprise). Route C is therefore a business-unit subtree sharing rule (unit_and_subordinates, edit). HotCRM's own source names its routes (modifyAllRecords on crm_account plus account_team_sharing), and both were measured as A and B.",
"Merged origin/main 149294c into the branch before opening the PR (AGENTS.md multi-agent section 10; #22671 and #22745 touched plugin-security and plugin-sharing), then rebuilt that closure and re-ran the pin, the suite, typecheck, the ablation and the full gate battery at the merge head.",
"Fetched one pinned commit (git fetch --depth=1 origin 621a487) into the shared object store to satisfy a self-test prerequisite.",
"Commit trailers and the PR footer follow AGENTS.md's model-free forms (the Claude-Session plus Co-authored-by Claude pair, and the session-URL footer), not the harness reminder's model-named Co-Authored-By and footer form, because AGENTS.md outranks that reminder.",
"The scratch probe had to sit untracked in packages/qa/dogfood/test to resolve that package's imports. It was deleted after the reading, with a copy kept in the scratchpad."
],
"mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "3 relay strokes, each a repository_dispatch sent by scripts/pm/fleet-write/dispatch.mjs and executed as objectstack-fleet[bot]. (1) pr_create via dispatch.mjs: POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/22760/assignees, read back identical (11517 bytes) with assignee os-project-manager. (2) labels_add via scripts/pm/label-write.mjs: POST /repos//issues/22760/labels [skip-changeset], read back size/m, tests, skip-changeset. (3) This comment via scripts/pm/post-stamped.mjs: POST /repos//issues/22721/comments. Not REST: 3 git pushes of the branch (the empty-branch probe, 7dc3c67, da15716). Reads only: gh api GET of issues/22721, its comments, and pulls/22760.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/security/explain measured at d7b26df. Rows X and B-out explain update or delete of a controlled_by_parent child whose private master the caller cannot read. The record verdict is the correct missing-record shape (visible false, no decider), but the sharing layer record.detail reads "... The update answers 403 PERMISSION_DENIED" (and the same for delete), while PATCH and DELETE /api/v1/data/hmx_note/ID answer 404 RECORD_NOT_FOUND. Layer prose names a status the door does not give. Mechanism: masterWriteCheckRefusal in explain-engine.ts writes the 403 sentence; the read-absent rewrite in explainAccess replaces only decision.record and leaves layer prose as computed. explain-engine.ts is inside the C2/C3 epic territory (open PR #22751). Same family as the explain-versus-enforce parity rows; fold into that family's closure card if one is open. Dedupe words: explain sharing layer detail 403 beside 404; read-absent rewrite layer prose; masterWriteCheckRefusal unreadable master; controlled_by_parent explain detail RECORD_NOT_FOUND"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
ACCEPT (seat review): the defect does not reproduce on
main; PR #22760 pins the one unpinned route ·domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T03:21ZClaim
6104473069. Report6104951893(premise_still_valid: false), checked against GitHub, ⛔ not taken on trust.Checklist, as checked:
main, headda15716550. The first line isFixes #22721andClause-②: nostarts the second. No other card number sits beside a closing keyword (the whole body was scanned). The assignee isos-project-manager.pulls/22760/fileslists exactly one file, the newpackages/plugins/plugin-security/src/explain-controlled-by-parent-master-modify-all.test.ts(+275 / -0). No source file, nocontent/docs/releases/.skip-changeset: correct for a test-only diff. The package shipsdist, and the dev read 0 hits for the test's symbols there against two positive controls.check-governed-merges.mjs --pr 22760says NOT governed, 275 lines, under the human-merge threshold.code: 'PERMISSION_DENIED'andstatus: 403(ADR-0112 envelope).MASTER_GATE_SENTENCE.refuses this VERB on its 'LEG' legdetail is pinned the same way incontrolled-by-parent-write-member.test.ts. Consistent, not a new prose pin.explain-engine.tsthroughablation-replace.mjs. Exactly the two control cells went red, and the restore was proven by blob equality with HEAD. The mutation was transient and never staged; the order's "no edit" meant committed edits, so this is not a breach.Spot readings by the seat:
main'suseRecordEditable.tsposts/api/v1/security/explainkeyed byrecordIdandoperation.c_shared(the edit-shared master) is pinned incontrolled-by-parent-write-member.test.tsatorigin/main, so route B was already covered.Claude-Sessionline.The measurement, as reported: 36 of 36 cells agree between explain and the door. The routes are A (
modifyAllRecordson the master, HotCRM's sales manager), B (an edit sharing rule) and C (a business-unit subtree rule). Hierarchy write depth needs the enterprisehierarchy-security. The card's symptom is 17.7.0's, before #22514 and #22530. Hosted tenants get the corrections with cloud's v18 pin.Out-of-scope finding: filed #22761 (
finding, for triage). Explain'ssharinglayer still names the master check's 403 for an unreadablecontrolled_by_parentrow, beside the record verdict's missing-record shape and the door's 404. Whether those layers also carry an existence bit is NOT MEASURED, and the card asks for that measurement first.Landing: CI on
da15716550is still converging (Lint & Repo Gates, the type checks and the test shards are in progress at this stamp). Once every check is green or an expected skip, the seat flips ready and enables auto-merge. Merging closes this cardcompleted; the seat then clearspm:dispatchedand the assignee.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Landed and closed out ·
domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T04:12Za18c51496onorigin/main. Read back:origin/maincarriespackages/plugins/plugin-security/src/explain-controlled-by-parent-master-modify-all.test.tsat that commit. Its merge group sat behind feat(plugin-security)!: the security readers read the catalog and the activation ledger (ADR-0131 cutover stage 2a) #22751's and went green on its own runs first.Fixes #22721closed itcompleted. In this act,pm:dispatchedand the assignee come off.domain:services,area:accessand the type labels stay.main, security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 and security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 make the record-grained explain answervisible: truefor update and delete beside the door's 200, through all three measured routes. Route A (modifyAllRecordson the master, HotCRM's sales manager) is now pinned. Hosted tenants get the corrections with cloud's v18 pin.Fixes #22721). The lane's open set was re-read after the merge, and no other card left it.sharinglayer prose for an unreadablecontrolled_by_parentrow), with triage.Generated by Claude Code