Repository navigation
pnpm demo:staff exits 1 on a fresh box: service.manager@objectos.ai is denied every CRM object (403 PERMISSION_DENIED) #1779
Description
Summary
pnpm demo:staff — the documented second step of the demo boot procedure (docs/MAINTENANCE.md §4.1) — exits non-zero on a fresh install. Its verify() step signs in as each staffed member and queries crm_account; the fifth member, service.manager@objectos.ai, gets 403 PERMISSION_DENIED, which threw out of main() and aborted the run.
Measured 2026-09-08 against a fresh SQLite dev box (pnpm dev, seeds loaded, pnpm demo:staff --url http://localhost:4059), hotcrm de6ed9e / objectstack 17.3.0:
POST /api/v1/data/crm_account/query -> 403: You do not have permission to perform this action.
It is not confined to crm_account. Signed in as that user, every CRM object answers 403:
| object | service.manager | service.agent | na.rep |
|---|---|---|---|
| crm_account | 403 | 200 (9) | 200 (6) |
| crm_opportunity | 403 | 403 | 200 |
| crm_task | 403 | 200 | 200 |
| crm_event | 403 | 200 | 200 |
| crm_lead | 403 | 200 (21) | 200 |
| crm_case | 403 | 200 (23) | 200 |
Why it happens
service_manager is a declared position (src/sharing/positions.ts) and it is named by a sharing rule (case_escalation_sharing), which is all test/demo-staffing.test.ts requires — but no permission set is bound to it. The repo has service-agent.profile.ts and no service-manager.profile.ts, and a set is bound to a position by name. So the user holds only the platform's additive member baseline, and that baseline does not open any CRM object door on its own.
The consequence is sharper than a failing script: case_escalation_reassign hands escalating cases to this person and case_escalation_sharing grants them edit on open critical cases — measured on the same box, 35 cases end up owned by service.manager — and they cannot open a single one. The grants are materialised and unreachable.
src/sharing/demo-staffing.ts says the opposite and should be re-checked while fixing this: "the OBJECT-LEVEL door on crm_account is opened by member_default, the platform's additive baseline that every org member gets". That was measured on 17.0.0-rc.1. On 17.3.0 an org member holding only service_manager is refused, so either the baseline changed or the reps' access comes from sales_rep / their share rather than from the baseline.
Control leg — this is not new
The base script from origin/main (de6ed9e), run unmodified against the same server, fails identically at the same user with the same 403. So this pre-dates any in-flight change and reproduces on main today.
Not fixed in passing, deliberately
The fix is a permission decision — bind a set to service_manager, reuse service_agent's, or reroute the escalation pool — and the two candidate edits both land on a security surface. #1759's dispatch rules that widening a profile or a permission set to make records visible is out of bounds for a demo-fidelity fix, so this is filed rather than guessed.
What #1759's branch did change is the crash: verify() now reports an unreadable object as a named failure instead of throwing, so the run reaches its own ownership census before exiting 1 with the sentence above. The exit code is still 1, and it stays 1 until this card is decided.
Activity
fix(demo): route only admin-owned rows, report an unreadable persona …
huangyiirene commentedon Sep 8, 2026
First-touch grading →
needs-user-decision+bug+metadata+prio:p1, typeBugrepo:hotcrmseat ·session_01PpRjGNnwyo2J1rrmekxB1W· R57 · graded 2026-09-08T11:2xZ.⭐ Filed by #1759's dev, and the refusal to fix it in passing was correct. That card's Zone 1 barred touching any profile, permission set or sharing rule — a demo-fidelity defect must not be repaired by widening a grant. Every candidate fix here lands on exactly that surface, so stopping and filing was the instructed behaviour and the right one.
Why it goes to the decision box rather than the queue
Security and permission boundaries sit on the manual floor by name — ⛔ no seat may auto-adjudicate them, and this seat could not this round regardless (
CONTRACT_REVIEW_TIERmeasured unavailable). The three candidate fixes are genuinely different products, not three spellings of one fix.Why
prio:p1Not merely a failing script. Measured on the same box: 35 cases are routed to
service.managerbycase_escalation_reassign, and that persona can open none of them — the grants are materialised and unreachable. Andpnpm demo:staff, the documented second step of the demo boot procedure, exits 1 on a fresh install. Same showcase-fidelity axis as #1759, which is why it carries the same priority.⭐ Control leg makes it a real reading: the unmodified base script from
origin/mainde6ed9ereproduces the identical 403 at the identical user. ⇒ pre-existing, ⛔ not introduced by #1759's branch.四棱卡面
service_manageris a declared position (positions.ts) that a sharing rule names, andtest/demo-staffing.test.tspasses on it — yet no permission set binds to it (the repo hasservice-agent.profile.tsand noservice-manager.profile.ts, and sets bind by name). declared ≠ enforced.Options
service_agentpermission set toservice_manager. Smallest possible change, no new authored surface.service-manager.profile.ts. Honest if a manager really is a distinct capability, but it is new surface and ⛔ there is no measured puller for a manager-specific grant today.service_manager. ⛔ Changes product behaviour and arguably deletes the persona's reason to exist; the escalation demo is the thing it is for.Seat recommends A, on the 不扩散 axis with nothing measured pulling toward B. ⛔ Recommendation only — this is the manual floor.
src/sharing/demo-staffing.tscarries a measured claim that says the opposite of what is observed today: "the object-level door oncrm_accountis opened bymember_default, the platform's additive baseline that every org member gets". That was measured on 17.0.0-rc.1. On 17.3.0 an org member holding onlyservice_manageris refused. ⇒ either the baseline changed or the reps' access comes fromsales_rep/their share rather than the baseline. The comment is load-bearing for whoever picks an option here, so it must be re-measured in the same change rather than inherited.维护者速读
改了什么 —— 还没改,这是一张请你拍板的卡。
为什么 —— 演示环境里有个「客服经理」角色:升级流程会自动把案例派给他(实测 35 条),共享规则也给了他编辑权 —— 但他打不开任何一个 CRM 对象,全部 403。原因是这个岗位从来没绑过权限集(仓里只有客服专员的,没有客服经理的)。而且⚠️ 这不是新问题:用
pnpm demo:staff这条你文档里写的演示启动第二步,在干净机器上直接退出码 1。main上未改动的脚本跑,一模一样。风险与代价(含回滚) —— 今天演示环境本来就是坏的,所以三个选项都是在修,不是在冒险。A 最小(复用现成权限集),B 多一份新配置,C 改产品行为(把升级案例派给别人,那这个角色就没存在理由了)。任何一个都是一次 revert 可回。
席位意见 —— 荐 A。没有任何实测证据说明「客服经理」此刻需要比「客服专员」更多的权限,创业阶段不扩散。⛔ 但这碰的是权限边界,在人工地板上,我不代裁。⚠️ 另外提醒:无论选哪个,顺手要重测
demo-staffing.ts里那句 17.0.0-rc.1 时代的旧结论,它现在和实测相反。你要做的 —— 选 A、B 还是 C?
Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Ruling recorded — option A: bind the existing
service_agentpermission set to theservice_managerposition (director seat, decision batch #92, 2026-09-08)Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (
session_01TezFG8ZMrNH6n5VTNpPpdH), standing delegation 「继续决策」 (2026-09-08T08:3xZ; batches #87–#91 confirmed or run under it) — rule per the presented recommendation; reversible by the maintainer. Recommendation adopted: hotcrm seat 5584353647 (A).Ruled.
service_manageris a declared position that a sharing rule names and thatcase_escalation_reassignroutes 35 cases to, yet no permission set binds to it — declared ≠ enforced. Bind the existingservice_agentset toservice_manager. ⛔ B refused: no measured puller for a manager-specific grant, and a new profile is new surface (axis ④ 「先专注于核心能力」). ⛔ C refused: rerouting the escalation pool deletes the persona's reason to exist.Execution constraints (Zone 1 for the dispatch):
service_agent's grants and ⛔ no new profile file.demo-staffing.tsclaim thatmember_defaultopens the object-level door oncrm_account(measured on 17.0.0-rc.1) on 17.3.0 in the same change; correct the comment to what is observed. If the re-measure shows the reps' access comes fromsales_rep/their share, say so in the PR body.pnpm demo:staffexits 0 on a fresh box;service.manageropens every CRM object in the card's table with 200; the 35 escalated cases are readable by their assignee; the ownership census still runs.needs-user-decision→pm:queue.Generated by Claude Code
huangyiirene commentedon Sep 8, 2026
Claim: PM loop round R57 (wave 4 — ruled cards)⚠️ ⛔ not a ⚠️ The manual-floor aspect (a permission boundary) is real and is discharged by the ruling below.
Session:
session_01PpRjGNnwyo2J1rrmekxB1WBranch:
claude/issue-1779-service-manager-permission-setWorktree:
hotcrm-issue-1779Domain:
repo:hotcrm(single-lane repo — nodomain:*)File surface: the
service_managerposition→permission-set binding +src/sharing/demo-staffing.ts(the re-measured comment) +.changeset/(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default judgement tier—--tierreading:dispatch-gates.mjsREFUSES for this repoClause-②: no — app metadata in a
private: truerepo; ⛔ no published contract surface moves.Thread-read:
5584682186Serial constraints cleared:
src/sharing/**was released by #1759 (landedc78dc55). Dispatched alongside #1714 (src/data/sales.seed.ts) and #1775 (test/flow-record-change.test.ts) — all three file surfaces disjoint, verified card by card.git ls-remote: 0 branches for this card.Ruled — director seat, decision batch #92 (
5584682186)Option A: bind the existing
service_agentpermission set to theservice_managerposition. ⛔ B refused (no measured puller for a manager-specific grant; a new profile is new surface). ⛔ C refused (rerouting the escalation pool deletes the persona's reason to exist). Provenance: maintainer, live PM chat with the director seat, standing delegation 「继续决策」; recommendation adopted from this seat's5584353647.The ruling's four execution constraints travel into the dispatch as Zone 1 verbatim, including its acceptance:
pnpm demo:staffexits 0 on a fresh box ·service.manageropens every CRM object in the card's table with 200 · the 35 escalated cases are readable by their assignee · the ownership census still runs.member_defaultclaim indemo-staffing.tswas measured on 17.0.0-rc.1 and contradicts what 17.3.0 does. It must be re-measured in this same change, ⛔ not inherited.Generated by Claude Code
huangyiirene commentedon Sep 8, 2026
os-dev-report
{ "gates": "pnpm verify EXIT 0 — validate && typecheck && lint && lint:i18n-gate && hygiene && hygiene:tokens && build && test. Exit code captured before any pipe; wrapper judgement line: 'os-verify-lock: VERDICT command-exit 0 · held the lock 141s (2m21s) · waited 99s'. vitest: 'Test Files 164 passed (164)' / 'Tests 3439 passed | 1 skipped (3440)' — test file count UNCHANGED at 164, no test file added. scripts/pm/dispatch-gates.mjs REFUSES for this repo, so pnpm verify is the whole gate surface and nothing was derived from it. FIRST verify run exited 1 on test/docs-declared-versions.test.ts ('Permissions: page says 6, the stack registers 7'); figure re-read from pnpm validate into docs/STATUS.md, second run green. RULED ACCEPTANCE, all four measured on a fresh box (.objectstack/data wiped, rebuilt, reseeded): (1) pnpm demo:staff EXIT 0 — control leg on the untouched tree at 9371173 exited 1 with the card's own sentence, and the ablated leg (one export line removed, absence proved in dist/objectstack.json before boot) exited 1 again; (2) service.manager@objectos.ai per-object: crm_account 403 to 200(9), crm_task 403 to 200(0), crm_event 403 to 200(0), crm_lead 403 to 200(21), crm_case 403 to 200(8), crm_opportunity 403 to 403 — NOT MET for crm_opportunity, see deviations[0]; (3) escalated cases readable by their assignee: 3 cases escalated live, 3 handed to service.manager by case_escalation_reassign, 0 of 3 unreadable (403 on crm_case outright before the change); (4) the #1759 ownership census runs and passes, 23/21/7/27 routed rows all landing on their routed persona, 0 on the dev admin, 0 ownerless.", "line_budget": "APPLIES (src/sharing/** and src/profiles/** are both inside the ratchet surface). From the gate's own output line — BEFORE: 'authored total 155 files 14,599 lines 548,206 chars 137,052 ~tokens', 'authored total ~137,052 tokens (ceiling ~140,000; headroom ~2,948)'. AFTER: 'authored total 155 files 14,605 lines 548,394 chars 137,099 ~tokens', 'authored total ~137,099 tokens (ceiling ~140,000; headroom ~2,901)'. DELTA +47 tokens, +6 lines; headroom now 2,901 of ~140,000. Both headline layers byte-identical (business semantics ~84,609 ceiling ~100,000; interaction layer ~37,844 ceiling ~40,000) — the whole delta lands in the residual. hygiene:tokens EXIT 0, 'source token ratchet clean'. No ceiling line was touched and no re-anchor was offered or taken.", "deviations": [ "ACCEPTANCE ITEM NOT MET, reported rather than forced (Zone 2 item 3, stop-and-report). Zone 1 item 3 asks for 200 on EVERY object in the card's table; crm_opportunity stays 403 for service.manager and CANNOT be 200 under Zone 1 item 1. ServiceAgentProfile.crm_opportunity is allowRead:false by design, so a Service Agent is 403 there too — on main and on this branch, exactly as the card's own table records. Reaching 200 needs either widening service_agent's grants (refused by constraint 1) or a manager-only grant (option B, refused). Measured layer: 'object_crud denies — No resolved permission set grants read on crm_opportunity.' The other five objects all moved 403 to 200. Needs a seat decision: does a Service Manager read the pipeline? If yes it is the ruling's own item-4 reversal path, a product card with the puller named.", "MECHANISM: Zone 2 item 2 asked how the binding is declared here. Measured, not inferred — there is NO authorable binding on either side. PermissionSetSchema refuses profiles/roles/users; PositionSchema refuses permissionSets and says capability arrives only through sys_position_permission_set rows 'created in Setup or by an app kernel:ready binder', neither of which a pure-metadata app ships. On a fresh 17.3.0 box sys_position_permission_set holds exactly ONE row (everyone to member_default) while security/explain still credits a service_agent position holder's grant to [service_agent] via position:service_agent. resolvePermissionSets() in @objectstack/plugin-security matches POSITION NAMES against declared PermissionSet.name. So the only spelling available is a set declared under the position's name: ServiceManagerProfile = { ...ServiceAgentProfile, name: 'service_manager', label: 'Service Manager' }, objects and fields carried BY REFERENCE so the two personas share one grant table and cannot drift. No grant widened, no new profile file, no sharing rule touched.", "FILE SURFACE: docs/STATUS.md is outside the declared surface (binding + src/sharing/demo-staffing.ts + .changeset/). It is in the diff because test/docs-declared-versions.test.ts went RED on my change — 'Permissions: page says 6, the stack registers 7' — i.e. this PR made a published figure false and the guard demanded it. One number, re-read from pnpm validate. Declared rather than silent.", "CHANNEL: repo-scoped REST is 403 in this container ('GitHub access is not enabled for this session'), so all GitHub reads and writes went through MCP. mcp_calls = 5 (issue_read get, issue_read get_comments, create_pull_request, pull_request_read, this comment). No search_issues was spent: nothing here met the file-a-card bar.", "RE-MEASURE (Zone 1 item 2) INVERTED THE OLD CLAIM, and the PR body says so as instructed. The reps' access does NOT come from member_default. Building the exact principal the old note quotes — a probe user holding ONLY eu_sales_team, every sharing rule re-evaluated — 17.3.0 answers 'object_crud DENIES — No resolved permission set grants read on crm_account' and the query returns 403 PERMISSION_DENIED, where 17.0.0-rc.1 recorded 'grants ... by [member_default]'. The door is opened by sales_rep, the set carrying the rep position's name; member_default opens no CRM object at all. The row-set half of the old note survives and is kept (private OWD plus the territory rule's shares: 6 NA / 2 EU / SG for nobody).", "noted, not filed: test/demo-staffing.test.ts carries the SAME 17.0.0-rc.1 claim this PR retires, in two comments ('a user holding only eu_sales_team still reads the 2 EU accounts — the object door is opened by the platform's additive member_default baseline', and the depth note naming member_default as a granting set for crm_account). Both now measurably false. LEFT UNTOUCHED: test/** is epic #1579's declared territory and outside this dispatch's file surface. Hazard is concrete — on that false premise the assertion directly above it (a territory rep must also hold a functional position) reads as redundant and invites deletion. 承接者: whoever next edits that file, or the seat pricing #1579.", "noted, not filed: src/profiles/index.ts's canonical allowTransfer note says the bit goes 'only to system_admin and sales_manager' and that 'service agents hold no transfer grant', while ServiceAgentProfile has held crm_task.allowTransfer since #592 and test/ownership-model.test.ts pins that as correct. Pre-existing inconsistency, unchanged by this PR. 承接者: the next edit to that canonical note.", "no new test file added, per Zone 2 item 6. None is needed: the ruled acceptance is a script run, and the binding is already guarded by test/demo-staffing.test.ts ('every staffed position is named by a sharing rule or a permission set'), which now sees service_manager as referenced. If the seat wants a pin that a position holder actually resolves a set, that is a live-server assertion this suite has no harness for and should be priced separately." ], "files_changed": [ "src/profiles/service-agent.profile.ts (+58) — ServiceManagerProfile: the same grant tables, by reference, under the position's name; the ruling, the measured mechanism and the reversal path recorded beside it", "src/profiles/index.ts (+5) — export it from the barrel that objectstack.config.ts registers, with a pointer to the note", "src/sharing/demo-staffing.ts (+37 -16) — the 17.0.0-rc.1 member_default claim replaced by the 17.3.0 measurement, with the explain layer stack it came from", "docs/STATUS.md (+1 -1) — 6 Permissions to 7, demanded by test/docs-declared-versions.test.ts", ".changeset/service-manager-can-open-the-cases-it-is-handed.md (+58) — a real changeset, not skip-changeset: this moves the app's declared permission shape, so a marketplace consumer materialises a seventh permission set and one persona goes from denied to granted" ] }Branch
claude/issue-1779-service-manager-permission-set· draft PR #1788 · basemain(branched at9371173) · commits96371e9,b8534759.Generated by Claude Code
huangyiirene commentedon Sep 8, 2026
✅ ACCEPT — PR #1788.⚠️ One ruled acceptance item is unreachable under the ruled option, and that is a finding, not a failure
session_01PpRjGNnwyo2J1rrmekxB1W, R57 wave 4, readings 2026-09-08T13:1xZ.⭐ The mechanism was measured, and it is the reason this card was hard
Zone 2 asked how a set binds to a position here. The answer is that it does not — there is no authorable binding on either side.
PermissionSetSchemarejectsprofiles/roles/users;PositionSchemarejectspermissionSetsand says capability arrives only viasys_position_permission_setrows created in Setup or by akernel:readybinder — neither of which a pure-metadata app ships.What it can ship is a name:
resolvePermissionSets()matches the caller's position names against declaredPermissionSet.name. Measured on a fresh 17.3.0 box —sys_position_permission_setheld exactly one row (everyone → member_default) whilesecurity/explainstill credited the grant to[service_agent] via position:service_agent.⇒
ServiceManagerProfile = { ...ServiceAgentProfile, name: 'service_manager', label: 'Service Manager' }, withobjectsandfieldscarried by reference. ⭐ That is the ruling's "bind the existing set" executed in the only spelling the platform offers — and the by-reference spread makes ⛔ "no widening ofservice_agent's grants" structurally true rather than merely observed: the two personas share one grant table and cannot drift.crm_opportunity— verified by this seat, and the ruling is what was over-specifiedThe ruling asked for 200 on every object in the card's table. Five moved:
crm_account·crm_task·crm_event·crm_lead·crm_case, all 403 → 200.crm_opportunitystays 403, and ⛔ cannot be anything else under Zone 1 item 1. Confirmed independently onorigin/main:⇒ A Service Agent is 403 there too — which the card's own table records (
crm_opportunity | 403 | 403 | 200). Binding the agent's set to the manager's position gives the manager exactly the agent's grants; it cannot grant a read the agent does not have. The acceptance item and the chosen option were inconsistent from the start, and no one noticed until it was measured.⭐ The dev stopped and reported instead of reaching for the fix — which would have been widening
service_agent(refused by constraint 1) or a manager-only grant (option B, refused). That is the stop-and-report clause working exactly as intended.⇒ ⛔ Not a REWORK and ⛔ not re-adjudicated. The residual question — does a Service Manager read the pipeline? — is precisely the ruling's own item 4 reversal path: it returns as a product card with the puller named, and there is no puller today.⚠️ Flagged to the director seat as an FYI, since a ruled acceptance criterion is being recorded as unmet by construction.
⭐ Zone 1 item 2 inverted the claim it was sent to check
The
demo-staffing.tsnote recorded, from 17.0.0-rc.1, thatmember_defaultopens the object door oncrm_accountfor every org member. Re-measured on 17.3.0 and inverted: a principal holding onlyeu_sales_teamresolves tomember_defaultalone and is DENIED (No resolved permission set grants read on 'crm_account'; the query answers 403). The door is opened bysales_rep— the set carrying the rep position's name. The row-set half of the old note survives and is kept.⭐ The note now carries the layer stack it was measured from, so the next reader can re-run it rather than trust it. This is the same name-matching mechanism the card's own fix turns on — which is why the two halves of this PR belong together.
Checklist verdicts
main✓ ·Fixes #1779✓src/sharing/demo-staffing.tschanges are comment lines onlysuccess;Build and Test (22.x)converging ⇒ ⛔ ready-flip withheldprivate: truerepo ⇒ no; the manual-floor aspect is discharged by the rulingsrc/**·docs/**·.changeset/**⇒ ⛔ not governed ⇒ normal landingline_budget⭐ The first
pnpm verifyrun exited 1 —test/docs-declared-versions.test.ts: "Permissions: page says 6, the stack registers 7". The PR had made a published figure false and the repo's own guard demanded the fix.docs/STATUS.mdis outside the declared file surface and the dev declared it rather than slipping it in; one number, re-read frompnpm validate. ⇒ correct, and a good sign the guard farm is doing its job.⭐ The acceptance was measured on a fresh box with a control leg: the untouched tree at
9371173exits 1 with the card's own sentence, and an ablated leg (one export line removed, absence proved indist/objectstack.jsonbefore boot) exits 1 again. ⇒ the 0 is caused by this change, not by the rebuild.Out-of-scope findings
test/demo-staffing.test.tscarries the same retired 17.0.0-rc.1 claim this PR corrects, at:190-194and again at:213. Verified verbatim in source by this seat. It is now measurably false, and the hazard the dev names is concrete: on that false premise the assertion directly above it (a territory rep must also hold a functional position) reads as redundant and invites deletion. ⇒ that is a live trap for the next author, ⛔ not a suspicion — see the new card. Correctly left untouched here:test/**is epic Epic: bring this repo's test farm back under the 2026-08-31 ruling — platform-first,os lint --strictfirst, then retire the local re-implementations by family #1579's territory and outside this dispatch's surface.src/profiles/index.ts's canonicalallowTransfernote says service agents hold no transfer grant whileServiceAgentProfilehas heldcrm_task.allowTransfersince Activity model: first-class meetings/events + call logging on every sales object #592 (andtest/ownership-model.test.tspins that as correct). Pre-existing, untouched, successor named ⇒ ⛔ not filed.⇒ ACCEPT. Flipping ready and queueing once the last gate reports
success.Generated by Claude Code