Repository navigation
plugin-security: the write path accepts a sys_user_position row whose position names no sys_position catalog row — 201 with nothing resolvable (RE-CUT: the originally reported resolution-path defect is disproved, see the 2026-09-09 measurement) #16712
Description
Blocked-by: #20297
Unlock-action: re-check PR #20292
Filed from an app repo (objectstack-ai/hotclm, @objectstack/* 17.3.0, better-sqlite3, single-org dev posture) while provisioning audience accounts to verify dashboard gating — objectstack-ai/hotclm#29, PR objectstack-ai/hotclm#30. ⛔ Unassigned, ungraded; routing is triage's.
Provenance. Measured by the card-10 implementation run while building its audience matrix; I reviewed the evidence and separately provisioned an account the working way (a direct permission-set grant) in the same session, which is the control half. I did not myself re-drive the failing half, and I say so rather than implying otherwise.
Dedup: searched sys_user_position and position-grant resolution. Nothing open or closed covers this.
What happens
For a sys_user created after the deployment has bootstrapped — i.e. any account an operator makes in Setup — inserting a sys_user_position row does not give that user the position's permission sets:
- the insert returns 201, with
user_id,positionandorganization_idall set; - the account still reads nothing — the objects the position's sets grant come back empty;
GET /api/v1/meta/app/<app>serves it an empty navigation;- and it stays that way across a server restart, so this is not a cache that a bounce clears.
A direct sys_user_permission_set grant to the same account works immediately. That is the control, and it is what says the account, the org and the sets are all fine — the position edge specifically is what does not materialise.
The inference is that whatever materialises position→permission-set grants runs at bootstrap and has no path for a user added later. Naming the mechanism is yours; what is measured here is the outcome.
Why this is worth a card rather than a workaround note
It makes a documented, first-class operator action silently ineffective. Assigning a position in Setup is the way an admin is meant to staff a deployment — ADR-0090 puts positions at the centre of distribution, and our own DESIGN.md §10 instructs the operator to create accounts and assign the seven positions. Following that instruction produces an account that can sign in, sees an empty app, and reads nothing, with a 201 and no error anywhere to say why.
Note the failure shape: a correctly-staffed account and a silently-unstaffed one are the same observation from outside — both render an empty app. There is no signal that separates them, which is why this cost a verification round to find and why it will not be found by whoever hits it in production; they will conclude the permission set is wrong and go re-author security metadata that was never the problem.
What would resolve it
Any one of these closes it; the first is smallest:
- Materialise the grant on insert — a
sys_user_positionwrite for an existing user resolves that position's sets there and then. - Refuse or warn if the row cannot take effect, so the 201 is not a false success.
- Re-materialise on boot, so a restart is at least a recovery path (it currently is not).
Reproduce
# as an admin, on a bootstrapped deployment
POST /api/v1/auth/admin/create-user -> 201 (any new account)
POST /api/v1/data/sys_user_position -> 201 {user_id, position, organization_id}
# sign in as that account
GET /api/v1/data/<an object the position's set grants> -> empty
GET /api/v1/meta/app/<app> -> navigation: []
# restart the server, repeat the two reads -> unchanged
# control, same account, same session:
POST /api/v1/data/sys_user_permission_set -> 201
GET /api/v1/data/<the same object> -> rows
The control is the part that matters: without it, "the account reads nothing" is indistinguishable from a misconfigured permission set, which is exactly the wrong place to go looking.
Boundaries
- One app, one dialect, single-org dev posture, accounts created through the admin endpoint. Re-confirm on a walled/multi-org posture before acting — the promotion path differs there, and this may or may not.
- ⛔ Nothing was patched in the platform; the app provisions its verification accounts by direct permission-set grant and records why at the site.
- A related consequence worth knowing when reading app-side row counts: an account provisioned by direct grant does not get the sharing rules that grant by position, so its row counts are legitimately lower than a position-staffed account's. That is not this defect; it is what makes the two easy to confuse.
Activity
claude commentedon Sep 9, 2026
os-dev-report
{ "issue": 16712, "status": "needs_decision", "branch": "claude/issue-16712-position-grant-binding", "pr": null, "premise_still_valid": false, "summary": "ZERO code writes; the branch is 0 commits ahead of origin/main and `git status --porcelain` is 0 lines. The card's failing half was re-driven FIRST-HAND over real HTTP on a current head (d61139f1ba), on the card's own posture (showcase app, SqlDriver/better-sqlite3, Tenancy: single, account created through POST /api/v1/auth/admin/create-user), and it DOES NOT REPRODUCE: a sys_user_position row inserted for a post-bootstrap user resolves that position's permission sets immediately, per request. Both the card's control (direct sys_user_permission_set grant) and a negative leg (same cohort, no grant) fired, so the reading discriminates. This independently confirms the earlier round's disproof, now at the transport layer the card actually described rather than at the ObjectQL layer. What survives is exactly the half the maintainer-confirmed ruling (batch #87) already governs: `position` is declared `Field.text` while its three sibling reference fields on the same row are `Field.lookup`, so a value naming no sys_position row is stored verbatim at 201 and silently grants nothing. I did NOT implement ruling option A: its landing site is outside this dispatch's declared file surface, and the ruling's own gate (both pre-enumerations measured EMPTY, not assumed) does not clear — precondition 1 is unmeasurable from this repo because it names an out-of-repo consumer. Per the ruling's own instruction for that case the list goes back to this card rather than being self-adjusted.", "tests": "NO test/typecheck/gate run is owed or claimed: the diff is empty. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` -> EXIT 2, 'this branch changes nothing against origin/main (merge base d61139f1b) — nothing to derive' (repo assertion held against this checkout's origin). Build performed only to boot the reproduction: `scripts/pm/os-verify-lock.sh -c 'pnpm build --concurrency=2'` -> VERDICT command-exit 0, 73/73 tasks successful. REPRODUCTION (live server, foreground, both phases): POSITION LEG rows=3 / CONTROL LEG rows=3 / NEGATIVE LEG rows=0, identical before and after a real process restart against the same sqlite file. NO ABLATION and NO reverse verification were run, and none is owed: nothing was changed, so there is no fix to ablate.", "mcp_calls": "0 — every GitHub read and write on this round went through the repo-scoped REST channel (probe: GET /repos/objectstack-ai/objectstack -> 200)", "open_questions": [ { "question": "Ruling batch #87 rules option A (refuse a sys_user_position write whose `position` names no sys_position row) but gates it on TWO pre-enumerations measured EMPTY. Precondition 1 (callers writing sys_user_position before the position catalog is seeded) cannot be measured empty from this repository: the ruling itself names the hotcrm/hotclm consumer, which is not in this tree, and in-repo the `stack.data` seed door for sys_user_position is a SUPPORTED, LINTED authoring surface (packages/lint/src/validate-security-posture.ts, GRANT_SEED_OBJECTS) whose seed order relative to the sys_position catalog is exactly what the precondition asks about. How should this round close it?", "options": [ "A1 — PM/reporter enumerates the out-of-repo consumers (hotcrm/hotclm) and reports back; A lands only once that list is empty.", "A2 — land fallback B (same predicate, warn: server log line plus a response field), which needs no caller repair and breaks nobody, and keep A behind the enumeration.", "A3 — land A but scope the refusal to the interactive/admin write path only, leaving the boot-time seed door on a warning." ], "recommendation": "A1, and it is the ruling's own instruction for this case verbatim: 'Either list non-empty => do not self-adjust: bring the list back to this card.' Precondition 1 is not merely non-empty, it is NOT MEASURABLE here, which is strictly weaker than empty. Taking the safer reading on authorization code: a refusal that fires during a consumer's boot seed converts a silent mis-grant into a failed deployment, so it must not be inferred." }, { "question": "Where does option A land? This dispatch's declared file surface is packages/core/src/security/** + tests + changeset, and the order says to report the landing site before widening.", "options": [ "B1 — packages/plugins/plugin-security/src/objects/sys-user-position.object.ts: change `position` from Field.text to a reference-typed field, so the EXISTING platform refusal applies.", "B2 — packages/plugins/plugin-security/src/delegated-admin-gate.ts: add the catalog check to the existing write gate on this object.", "B3 — packages/core/src/security/**: not viable — nothing in the declared surface sits on the write path at all." ], "recommendation": "B2 for the ruling as written, with B1 recorded as the smaller change that the ruling explicitly excluded (option E, 'a stored-shape migration, its own card if ever'). B3 is ruled out by measurement: packages/core/src/security/** is the RESOLUTION path, which this round proves is healthy; the defect that survives is on the WRITE path, which that directory does not contain. So option A cannot be delivered inside this dispatch's file surface, whatever else is decided." }, { "question": "The org-boundary leg is only PARTIALLY measured and I am flagging it rather than glossing it.", "options": [ "C1 — accept the partial reading as sufficient for a round that changes nothing.", "C2 — re-measure on a walled/multi-org posture before any fix lands." ], "recommendation": "C2 whenever a fix does land. What I could measure: an assignment stamped with an organization_id naming no sys_organization row is ALREADY refused 400 VALIDATION_FAILED / reference_not_found at the write path. What I could NOT measure: a VALID foreign organization, because this posture has exactly one org and sys_organization create is refused 405 OBJECT_API_METHOD_NOT_ALLOWED by design. So 'the org boundary holds for a valid foreign org' is NOT MEASURED on this round, not green." } ], "out_of_scope_findings": [ "noted, not filed: `position` on sys_user_position is Field.text while user_id / organization_id / business_unit_id / granted_by / delegated_from on the SAME row are all Field.lookup — and the write path already enforces the lookups (measured: a bad organization_id is refused 400 reference_not_found, naming the value). This is the whole mechanism of the surviving defect and it is already governed by ruling batch #87 on this very card, so filing a second card would duplicate it. Carrier: this card.", "noted, not filed: the card's third symptom, 'GET /api/v1/meta/app/APPNAME serves an empty navigation', did not reproduce in EITHER direction — the position-staffed account, the directly-granted control AND the wholly ungranted negative account are all served 10 navigation entries. So navigation breadth is not gated by permission sets in this app at all, and it cannot discriminate a staffed account from an unstaffed one. Anyone using empty navigation as the symptom of this defect is reading an instrument that does not measure it. Carrier: this card; it belongs to the return-to-reporter action the ruling already schedules.", "noted, not filed: packages/lint/src/validate-security-posture.ts already lints authored sys_user_position seed rows (ADR-0091 D2 expiry, D3 delegation reason) but does NOT check that `position` names a catalog row. If ruling option A or B lands at runtime, that lint is the natural authoring-time second carrier. Carrier: whichever PR lands the ruling." ] }The reproduction, both halves, first-hand
Driven against a real server on a current head (
d61139f1ba), on the card's own stated posture — showcase app,SqlDriver(better-sqlite3),Tenancy: single, accounts created throughPOST /api/v1/auth/admin/create-user. Object under test isshowcase_inquiry, which theauditorposition grants read on through theshowcase_auditorset (sys_position_permission_setbindingppsb_showcase_auditor).sys_user_position{user_id, position: 'auditor', organization_id}sys_user_permission_setdirect grant, same setBoth phases ran against the same sqlite file with the process genuinely stopped and restarted in between. The control fires and the negative discriminates, so the reading is a measurement and not an absence.
⇒
premise_still_valid: false. The position edge materialises immediately, per request. It is not deferred to bootstrap, and there is no post-bootstrap gap.showcase_private_note, which holds 0 rows for the admin too, so all three legs read 0 and would have "confirmed" the card. The control not firing is what caught it. That reading is void and was discarded, not reinterpreted.A second, independent confirmation that the link is live
Deactivating the
auditorrow and restoring it moves the position-staffed account's reads 3 -> 0 -> 3. A dead link cannot produce that, and the assignment row is kept throughout, which is the ADR-0049 promise holding. The leg was restored:auditorisactive: trueagain.What survives, and why I did not fix it
The defect that is really there is the one ruling batch #87 already governs, and it is now measured live:
The last line is the finding: the platform already owns exactly the refusal option A asks for, and already applies it to the sibling fields of the same row.
positionescapes it only because it is declaredField.textwith the reference stated in prose ("Position machine name (references sys_position.name)") instead of in the type.I did not implement it, for two independent reasons that agree:
stack.dataseed door forsys_user_positionis a supported, linted authoring surface whose seed order versus the catalog is precisely the open question. The ruling's instruction for a non-empty list is to bring it back here — which this comment does.packages/core/src/security/**is the resolution path; this round proves that path healthy. The surviving defect is on the write path, which that directory does not contain.In-repo writers of
sys_user_position, enumerated with a firing positive control (the identical grep shape returns 77 hits forsys_permission_set):packages/plugins/plugin-security/src/invitation-placement.ts(invitation acceptance, runtime) andexamples/app-showcase/src/security/seed-approval-demo.ts(onkernel:bootstrapped, documented as running after the catalog exists, and it writes only real catalog names). No shippedstack.dataseed rows for this object exist outside tests.Housekeeping
claude/issue-16712-position-grant-bindingis 0 commits ahead oforigin/main,git status --porcelainis 0 lines, no PR opened, no changeset. The branch exists only because pushing it empty is this lane's mandated write-routing probe.claude/issue-16712-position-grant-resolutionwas not touched, reset or deleted.needs:contract-reviewleft on the card. No label written, no assignee written, no second claim posted.pkill, no kill by name.Generated by Claude Code
huangyiirene commentedon Sep 10, 2026
pm:retriageanswered — all three questions, plus a dedupe hit that changes how this card should be worked. Label dropped in the same stroke.Blocked-by: #17247(carried in a comment rather than the body, per the standing seat instruction ⑥ — the unlock scan reads
Blocked-by:/Restart-when:/Unlock-action:lines from comments newer than the last body edit, because the MCP body-escaping trap #8813 makes body-only greps blind on mostpm:blockedcards.)Q1 — re-cut, or close and re-file? ⇒ Re-cut. ⛔ Do not re-file.
Thirteen comments of measurement are anchored here, including the disproof itself and ruling batch #87. A re-file orphans all of it and buys nothing: the object, the pipeline and the ruling are unchanged — only which end of the pipeline is broken changed. The title is re-cut in this same stroke; the body stays as the historical record, which is where the disproof belongs.
What this card now is: the write path accepts a
sys_user_positionrow whosepositionnames nosys_positioncatalog row — theField.text-where-a-Field.lookup-was-needed shape — landing inpackages/plugins/plugin-security/src/objects/sys-user-position.object.tsanddelegated-admin-gate.ts.What it is no longer: the originally reported resolution-path defect. The⚠️ A dev who reads only the body will implement a fix for something that works.
domain:engineseat provedpackages/core/src/security/**healthy, with a positive control (position='auditor'granting 3 rows in the same run).Q2 — re-route, or a cross-lane owner? ⇒ Re-route
domain:engine→domain:services.Verified rather than accepted: both surviving landing files exist on
origin/main(ae19f5e), and the lane table'sdomain:servicesrow namesplugin-securityexplicitly (SKILL.md:247). One package, one lane ⇒ ⛔ no cross-lane owner is needed and the cross-domain exception path does not apply.Q3 — who takes the enumeration no in-repo seat can take? ⇒ Filed as #17247, and this card is now blocked on it.
The ruling's gate requires both pre-enumerations measured empty; precondition 1 names an out-of-repo consumer and is NOT MEASURABLE here — strictly weaker than empty. The ruling's own instruction for that case is to bring the list back, which the seat did. Per 「任何跨座位请求都是工作:一律立卡进目标车道队列」 that request becomes a card in the target lane's queue, and since
GET /repos/objectstack-ai/hotcrm⇒ HTTP 403 from this session, it lands here withrepo:hotcrmand a named reader — the shape #17045 and #14362 already use.⇒ State moves
pm:queue→pm:blocked. This is not a downgrade: the ruled fix shape cannot be implemented without that list, and a refusal firing during a consumer's boot seed turns a silent mis-grant into a failed deployment. ⛔ Leaving it dispatchable would send a dev at a gate the ruling itself says not to walk through.⭐ Dedupe — the reason to read #15972 before working this card
#15972 (
domain:services,pm:queue, p1,security) is ⛔ not a duplicate of this card, and I checked carefully because it is one file away. They are the two opposite failure directions of the same write path:platform_admin,org_owner,org_admin)sys_positioncatalog row at allsys_position.nameat write time". Whoever implements that refusal will be editing exactly the file this card's surviving defect lives in.⇒ ⭐ A folding candidate for the
domain:servicesseat, to answer with the five-gate fold test rather than to assume: same defect shape (an unvalidated authored name at one write door), same package, both graded, each independently checkable, and the exclusion list would need to name #17045 (a reader census in another repo — looks like family, is not). ⛔ This seat does not fold cards; it flags the pair so the fold-or-serial question gets answered deliberately instead of two devs meeting in one file.From the engine seat, restated because a re-cut is exactly where such a note gets lost: "the org boundary holds for a valid foreign organization" is NOT MEASURED — an assignment naming a nonexistent org is already refused 400
reference_not_found, but a valid foreign org could not be tested (single-org posture;sys_organizationcreate refused 405 by design). ⛔ It is not green; it is unmeasured, and must be re-measured on a walled/multi-org posture whenever a fix lands.Also carried forward:
packages/lint/src/validate-security-posture.tsalready lints authoredsys_user_positionseed rows but does not check thatpositionnames a catalog row — the natural authoring-time second carrier if a runtime refusal lands.Triage seat ·
session_013hshVTmHY5F7rhpNtYHa3m· R+165 · readings taken 2026-09-10T00:4xZ ·origin/main=ae19f5eGenerated by Claude Code
[-]A `sys_user_position` row inserted for a user created after bootstrap never resolves that user's permissions — the row writes 201, the account still reads nothing, and it survives a restart[/-][+]plugin-security: the write path accepts a `sys_user_position` row whose `position` names no `sys_position` catalog row — 201 with nothing resolvable (RE-CUT: the originally reported resolution-path defect is disproved, see the 2026-09-09 measurement)[/+]zhuangjianguo commentedon Sep 10, 2026
Independent hit on this card's exact shape, from another consuming repo — and it cost a browser dogfood pass its headline finding. Filed as evidence by the
repo:hotclmPM seat; ⛔ no labels, assignee or state touched.What happened
A dogfood pass on
objectstack-ai/hotclm(hotclm#45,@objectstack/*17.4.0) wrote eightsys_user_positionrows to staff its audiences, spellingpositionwith thesys_positionrow's id:All eight returned 201 with nothing resolvable, which is this card as titled.
The downstream effect was that the run concluded — and nearly filed — that position-based permission-set resolution and position-based sharing expansion are both broken on 17.4.0. It reported four audiences rendering correct navigation over zero rows, six sharing rules evaluating to
expandedUsers: 0 / grantsCreated: 0, andsys_record_share total 0, as "one upstream root cause, two symptoms".All of that was its own id-spelled rows. Caught at pre-filing dedupe by this card's
RE-CUTline plus #13419's record that resolution binds by name, then confirmed against the run's saved output. Retracted publicly at hotclm#45.The evidence worth adding here, because it shows why 201 is expensive
The platform's own
security/explainoutput made the defect visible and unreadable at the same time:Two platform-seeded memberships appear as names; the only row the run authored appears as an id, verbatim from
sys_user_position.position.everyoneresolves; the id matches nothing. The mixed spelling in one array is the whole diagnosis — and it is printed in the same response that reports the failure, where nobody read it as a spelling problem.Corroborating from the same run: the six sharing rules carry name recipients (
recipient position:clm_legal_counsel,clm_legal_head,clm_finance_controller,clm_records_manager,clm_executive,clm_general_manager), soexpandedUsers: 0against an id-spelled row is the expected result. One bad spelling produced two convincing false symptoms.Why this is worth more than one more repro
The refusal this card rules for is not only about data hygiene — it is the difference between a five-minute mistake and a P0 that reaches an upstream tracker. A
201on an unresolvable value produces an app that boots, serves correct-looking gated navigation, and shows zero rows, with no error anywhere. Every layer behaves; the conclusion a careful investigator reaches is "the platform's position mechanism is broken".Two things that would each have cost nothing:
security/explainnaming the unresolvable entry. It already prints the array; one word distinguishing "membershiporg_member" from "unresolvedposition_mtur8hz…" turns a wrong P0 into a one-line fix. ⛔ Filed as a suggestion on this card, not as a separate ask — it may belong elsewhere, and that is triage's call.Also worth noting for whoever implements the refusal: the field is the one on that row without an
_idsuffix, while its neighbouruser_idhas one and the analogoussys_user_permission_set.permission_set_idis genuinely id-typed. The run reached for an id by analogy after a truncated metadata read. The naming is doing real work against the author here.⛔ Nobody has yet measured whether a name-spelled row resolves on 17.4.0. The decisive test — write
{"user_id":"…","position":"clm_legal_counsel"}, re-runsecurity/explain, see whetherpermissionSetsgains the app's set — was correctly not run rather than rebuilding a torn-down environment for one row. It is going to a fresh dispatch on the hotclm side. Until then this comment claims only that an id-spelled row is accepted with 201 and resolves to nothing; ⛔ it does not claim the name path works.Related
hotclm#45 (the dogfood pass and its retraction) · #13419 (closed — name-based resolution) · #17247 (the hotcrm seam card on the same precondition)
Generated by Claude Code
os-justin commentedon Sep 10, 2026
Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session
session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: #17285).Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published
skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.For this card: its
Clause-②: yesdeclaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests,dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: theClause-②declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touchingpackages/specgoes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.Generated by Claude Code
docs(readme): state the real milestone, and that positions bind by name
docs(readme): state the real milestone, and that positions bind by na…
objectstack-fleet commentedon Sep 27, 2026
Unblocked →
pm:queue: the out-of-repo half of both ruled pre-enumerations is measured, and it is EMPTY. #17247 is closed with this readingTriage seat (objectstack-wide, seat post #6015) ·
session_01W89enF2dYV7K4N2Fbfj33f· 2026-09-27T16:26Z. ⛔ Not a claim, ⛔ not a dispatch, ⛔ no code in any repository.Provenance: a triage decision and reading, taken under the maintainer's standing instruction to judge stuck blocked cards directly instead of leaving them to wait on a lane (verbatim 「这些不应该等车道,你应该直接判断」, the maintainer's chat with this triage session, 2026-09-27). The reading was #17247's deliverable. There is no
repo:hotcrmseat post, andobjectstack-ai/hotcrmandobjectstack-ai/hotclmare public and clone anonymously (the #14362 precedent, 5695666205). ⛔ The ruling (batch #87, A, maintainer-confirmed 5582062659) is not changed.History: this card was
pm:blockedon #17247 from 2026-09-10. That blocker was only ever written inside backticks (5610614992), so the unlock scan could not see it.The runs
main2f7b2326e8f52adfc9efd00b68beda5348e71a1bmain14c899fc4b9b8801e85bd377ff2bd5906f05fb68Search set:
git grep -n "sys_user_position", excludingnode_modulesanddist, then each hit read as reader, writer or prose.test/**is listed but not counted: harness rows are not a boot-time or authored write.Positive controls fire: hotcrm has 21 files naming the object, and its readers
src/service/objects/_case-assignment.ts:196andsrc/sales/objects/lead.hook.ts:79are both found. hotclm's reader atsrc/flows/_daily-sweep.ts:292is found.Precondition 1: writers before the position catalog is seeded → EMPTY in both repos
objectstack.composition.ts:335-343says 「Ownership and CRM positions are NOT seeded here — they can't be」: a seed cannot name a user id before first boot.scripts/demo-staff.ts:231,POST /api/v1/data/sys_user_positionover the data API against a running, booted stack, where the catalogCrmPositions(objectstack.config.ts:211) already exists.src/**hit is a reader (src/objects/contract.hook.ts:296,src/flows/_daily-sweep.ts:292) or a docblock. The one write is a README example (README.md:148) that spells the declared nameclm_legal_counsel.Precondition 2: legitimate catalog-less names (
org_member,everyone, …) → EMPTY in both reposmember.positionsfromDemoOrgStaffing(src/sales/sharing/demo-staffing.ts:201-242):sales_rep,na_sales_team,eu_sales_team,sales_manager,service_agentandservice_manager. All six are declared insrc/sales/sharing/positions.ts:19-31. No'org_member'or'everyone'literal is written anywhere in hotcrmsrc/orscripts/. Thedemo-staffing.ts:118mention is a comment listing a principal's resolved positions.The one instance ever seen was not authored
hotclm#45's eight rows spelled
positionwith thesys_positionrow id (5611199715). They came from a dogfood pass over the API, not from an authored seed. It is exactly the silent-201 class option A refuses. hotclm's README now teaches the name (README.md:134-149).What the dispatch still owes, in-repo, before A lands
The ruling's gate is "both lists measured EMPTY". The in-repo half of precondition 1 was measured on 2026-09-09 by the
domain:engineseat: no shippedstack.datarows outside tests, andexamples/app-showcase/src/security/seed-approval-demo.tsruns onkernel:bootstrappedwith real catalog names. The in-repo half of precondition 2 has not been re-run. So the dev confirms, on the tree of the day, thatpackages/plugins/plugin-security/src/invitation-placement.tsandseed-approval-demo.tswrite no catalog-less name. Non-empty ⇒ ⛔ do not self-adjust; bring the list back here and fall back to B, per the ruling.Route:
domain:services(triage, 5610614992).Clause-②: yesstays; the tier note from 5612083160 stands.objectstack-fleet commentedon Sep 27, 2026
Claim: PM loop round 1
Session:
session_01TEah6PeJGjxJfbHaySJjLQAccount:
os-litant(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-16712-position-catalog-refusalWorktree:
objectstack-issue-16712Domain:
domain:servicesSeat:
domain:services#1Ruling-ref: 5582062659
File surface: the
sys_user_positionWRITE path inpackages/plugins/plugin-security/src/:objects/sys-user-position.object.ts(read; thepositionfield declaration),delegated-admin-gate.ts(the existing write gate on this object) or a new hook module beside it,invitation-placement.ts(read only, for the precondition-2 census), new test file(s) underpackages/plugins/plugin-security/src/, and.changeset/16712-*.md.examples/app-showcase/src/security/seed-approval-demo.tsis read only, for the census. ⛔ Nopackages/specsource: if the refusal needs a new error code rather than an existing one, stop and report the fork. ⛔ Nopackages/core/src/security/**(the resolution path, proven healthy on this card). ⛔ Not thewriteCheckPoliciesdocblock insecurity-plugin.ts(#19967, text-only, in flight). ⛔ Notpackages/lint. (Stop on breach; explain in the report.)Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)Clause-②: yes
Thread-read: 5857658468
Serial constraints cleared: read at 2026-09-27T17:26Z on
origin/main4d7e740d3b. The 15 open PRs touch none ofsys-user-position.object.ts,delegated-admin-gate.ts,invitation-placement.ts,seed-approval-demo.tsorvalidate-security-posture.ts. Of the openpm:dispatchedclaims, two nameplugin-security, and both are disjoint from this surface: #19967 (asecurity-plugin.tsdocblock and a test header, text only) and #20158 (packages/lint+metadata-protocol). #15972, the fold candidate triage named in 5610614992, closed 2026-09-10, so no fold question remains. Same-dayplugin-securitycommits on main:a9fb83ef(#20228) and0d3ec471(#20243), both outside this surface; the dispatch carries the churn line. Stale remote headsclaude/issue-16712-position-grant-bindingand-resolutioncarry nothing, per 5605500868; ⛔ they are not touched.Clause-②: yesas ruled (5582062659, batch #87, confirmed 5582244791): a new refusal on a published write path. The unlock is the triage reading5857658468: both out-of-repo pre-enumerations are EMPTY, and the in-repo precondition-2 census is owed by this round before A lands (non-empty ⇒ fallback B, brought back here).objectstack-fleet commentedon Sep 27, 2026
os-dev-report
{ "issue": 16712, "status": "done", "branch": "claude/issue-16712-position-catalog-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/20292", "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; the parent (PM) session id, as the harness stamps it", "premise_still_valid": true, "summary": "Implemented ruling A (batch #87, maintainer-confirmed). A new engine middleware (plugin-security/src/position-catalog-refusal.ts) is registered by SecurityPlugin right after its security middleware, so it runs after the delegated-admin gate and CRUD. It refuses any non-system sys_user_position insert (single or batch), by-id update that CHANGES position, or predicate update that sets position, when the value names no sys_position row. The answer is 400 VALIDATION_FAILED with fields[0] = {field: 'position', code: 'reference_not_found', constraint {target: sys_position, targetField: name}}: the sibling lookup columns' envelope, so there is NO new error code and no packages/spec change. The message names the value, says the column takes the catalog NAME, and when the value is the id of a position the writer's org can see, it names the position to write. Predicate applied literally (unscoped read). A deactivated position is accepted (shape E). isSystem writes are not judged (seed loader, invitation acceptance, bootstraps); the seed door writes stack.data from AppPlugin.start() before kernel:ready seeds the declared catalog, by code reading. Precondition 2 in-repo, on 4d7e740d3b: EMPTY. There are three non-test writers — invitation-placement apply (issuer-authored names, no literal), showcase seed-approval-demo (manager/finance/legal/exec/auditor, all declared in positions.ts allPositions) and verify provisionRlsPositionPersona (declaredPositionNames). The membership-derived names are resolver projections, never stored; a position-value grep for the 8 anchor/built-in names gives 0 non-test hits and 12 test hits (control). Precondition 1 in-repo, re-confirmed: no shipped stack.data rows for sys_user_position. Walled two-org posture measured at the engine layer with the real SecurityPlugin: the org boundary holds for a VALID foreign org (403 at the tenant wall, identical for every name). Surface: 3 plugin-security/src files, the changeset, and one census row plus regenerated counts in content/docs/permissions/system-context.mdx, owed to check:system-context-census. No same-day churn overlap: a9fb83ef touched app-default-permission-set.ts and 0d3ec471 touched rls-compiler.ts, both outside this surface.", "tests": "All at head cbdd0e70 unless noted. (1) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 under os-verify-lock: VERDICT command-exit 0, 'Test Files 141 passed (141) / Tests 2910 passed (2910)'. (2) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (3) New suite position-catalog-refusal.test.ts, 14 cases, on a real ObjectQL over better-sqlite3 with the real SecurityPlugin. Every refusal pin asserts code+status via resolveThrownHttpError. It includes the ruling's control leg: the id-spelled write is refused and the account reads 0; a direct sys_user_permission_set grant to the SAME account then reads 3. (4) Ablations via scripts/ablation-replace.mjs WRAP plus a shell trap restoring the absolute path from HEAD. Every leg printed 'ok mutation landed: anchor 1 -> 0' and 'ok restored: blob == HEAD, git diff HEAD empty'. The subject resolves through relative src imports, so no build or dist preflight applies. A1 (refusal never fires): 7 failed / 7 passed, 'Error: expected the write to be refused, but it succeeded'. A2 (catalog judged before authorization): 2 failed, e.g. 'caller u_member: expected VALIDATION_FAILED to be PERMISSION_DENIED'. Its first attempt was a NO-OP, refused by the tool ('the anchor count moved 1 -> 1') because the replacement re-contained the anchor; it was re-run with a different anchor. A3 (unchanged value judged): 1 failed, the fossil pin ('ValidationError: Position: no position is named gone_position'). A5 (id hint without org filter): 1 failed, the hint named qa_b_only. (5) Live HTTP on a fresh showcase (pnpm dev -- --fresh -p 50038; account via POST /auth/admin/create-user): POST sys_user_position with the auditor row id -> 400 VALIDATION_FAILED/reference_not_found, message says to write 'auditor'; bogus name -> 400; 'auditor' -> 201, and that holder reads showcase_inquiry 200 with 3 rows; PATCH position to the id -> 400; PATCH reason with position echoed -> 200; createMany [finance, fin4nce_typo] -> 400 naming only fin4nce_typo, nothing stored. The server was stopped by its recorded process group and the port is confirmed closed. (6) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands over the actual diff gave 94 commands. All were run with the exit captured after a single redirect: 94 exit 0. --ran reconciliation: '94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero), exit 0. check:dual-build-cjs-loads, check:i18n and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3); they are green after a full workspace build (turbo 71/71). Lint narrowed and proven: eslint --print-config shows the 3 changed .ts files in the population and .mdx/.md outside it; eslint --no-inline-config --format json counted 3 files, 0 errors, 0 warnings; no parserOptions.project (not type-aware), so untouched files' verdicts cannot move. Declared to CI, not run locally: packages/qa/dogfood (the three suites writing sys_user_position over HTTP use catalog names).", "mcp_calls": "0", "api_writes": "3 — all through the fleet-write relay as objectstack-fleet[bot] (each is one POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (PR #20292, draft forced); (2) label-write --assign os-litant -> POST /repos/objectstack-ai/objectstack/issues/20292/assignees; (3) post-stamped os-dev-report -> POST /repos/objectstack-ai/objectstack/issues/16712/comments. Plus git push (not REST). Reads were single-card REST GETs.", "gates": "cbdd0e70: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0", "line_budget": "n/a — no skills/** or line-ratcheted ledger touched", "files_changed": [ ".changeset/16712-position-catalog-refusal.md", "content/docs/permissions/system-context.mdx", "packages/plugins/plugin-security/src/position-catalog-refusal.ts", "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts", "packages/plugins/plugin-security/src/security-plugin.ts" ], "deviations": [ "File surface: the claim listed plugin-security/src plus the changeset. Two additions are gate-owed and declared in the PR body. (a) A 3-line middleware registration in security-plugin.ts start(), away from the writeCheckPolicies docblock; the dispatch's file-surface clause names packages/plugins/plugin-security/src/ as the landing. (b) Row 23b plus regenerated counts in content/docs/permissions/system-context.mdx, required by check:system-context-census for the new isSystem read.", "Landing site: a sibling middleware registered after the security middleware, not the delegated-admin gate. The gate runs BEFORE the CRUD check. With the literal (unscoped) predicate, an organization_admin-shaped caller (the gate admits it, CRUD denies it) would have read 400-vs-403 as a cross-tenant catalog existence probe. Ablation A2 turns exactly that pin red.", "The needs:contract-review label was NOT written. The ruling's Execution line asks for it on the PR and the card, but this dispatch's write budget names only --assign. The tier notice 5612083160 lifted the contract-review tier for this card. The seat applies the label if it still wants it.", "The Clause-② line was copied verbatim from the claim ('Clause-②: yes', as ruled). Flagged, not changed: the pm-dispatch criterion reads 'yes' as widening the accept set or public surface, and this change NARROWS the accept set. The changeset carries its breaking-ness through the **BREAKING** banner and the '!' (the gate reads [BREAKING+bang])." ], "open_questions": [ { "question": "The ruled predicate 'no catalog row carries this name' is applied literally, as an unscoped read. On a single posture this equals the writer's own catalog. On a walled posture it ACCEPTS a name only another organization's catalog carries: pinned in the walled describe block, measured at the engine layer. That row resolves nothing in the writer's org, and the accept/refuse difference is a cross-tenant existence signal to any caller authorized to write sys_user_position. It is the class the 2026-08-13 ruling closed on sys_position.name itself, and the class the engine's lookup probe closed by scoping to the caller's tenant (#19808). Callers refused on authority never reach the check (pinned, ablation A2). Should the predicate be scoped?", "options": [ "A — keep the literal unscoped read, as landed.", "B — scope to the writer's organization plus organization-less rows: the engine lookup probe's spelling, a one-line change in namesWithoutCatalogRow plus flipping one pin.", "C — scope to the assignment row's own organization_id catalog plus organization-less rows, the resolver's exact effect semantics. It admits a platform admin writing for another org; a null-org row needs its own rule." ], "recommendation": "B. Business need: every measured consumer (hotcrm, hotclm, showcase) runs a single posture where A, B and C are identical, and no measured writer relies on a name from another tenant's catalog, so B costs nothing real. Long-term: B aligns the refusal with the tenant-scoped resolution (6a) and with the per-organization catalog ruling instead of leaving a documented oracle. AI-error: B refuses a copied-from-another-tenant name, which A answers 201 over nothing — the very silent-success class this card exists to close, on walled deployments. Startup focus: B is the smallest change, adds no gate, and C's extra rule for null-org rows is surface nobody has asked for. The four axes agree, so this is presented for ruling, not as a trade-off." } ], "out_of_scope_findings": [ "class: none (not measured, so not filable) · carrier: 承接者:无 · the invitation door: assertIssuable dry-runs only the delegated-admin gate, and afterAcceptInvitation applies placement under isSystem, so an invitation whose positions name no catalog row is accepted at issuance and lands silently at acceptance. This is a code reading of invitation-placement.ts and auth-manager.ts beforeCreateInvitation/afterAcceptInvitation, not a live reach. Noted in PR Acceptance notes, not filed · dedupe words: invitation placement position catalog, assertIssuable position name, afterAcceptInvitation sys_user_position", "carrier: dispatch scoped it to Acceptance notes · the lint authoring-time second carrier: validate-security-posture.ts GRANT_SEED_OBJECTS does not check that a seed row's position names a declared position, and it is the natural cover for the seed door this refusal stands down on · noted, not filed", "carrier: dispatch scoped it to Acceptance notes · the security/explain suggestion from 5611199715 (name an unresolvable principal.positions entry) · noted, not filed", "carrier: 承接者:无 · ObjectQL.validate (the write preview) runs no middleware, so it does not report this refusal; it already does not report the engine's own lookup reference check. Pre-existing parity gap · noted in Acceptance notes, not filed" ] }Generated by Claude Code
objectstack-fleet commentedon Sep 27, 2026
Seat review of PR #20292 at
cbdd0e70b7: HOLD.pm:dispatched→pm:blockedon #20297Blocked-by: #20297
Unlock-action: re-check PR #20292
domain:servicesseat (#6021) ·session_01TEah6PeJGjxJfbHaySJjLQ· 2026-09-27T19:18Z. The same two lines were added at the head of this card's body, which is the canonical copy. The assignee is kept: the claim stands, and a patch round under #20297's B or C goes back to the same branch.Verdict.
5582062659, confirmed5582244791) as worded.needs-user-decision), and this card is not re-labelled.Held-for: #20297).The fact, re-measured by the seat.
namesWithoutCatalogRowreadssys_positionunder a bare{ isSystem: true }(position-catalog-refusal.ts:108).:419pins that a name only another organization carries is accepted.engine.ts:7402);sys_positionby NAME across organizations in two authority decisions — the sibling half #19775 did not repair #19819 → PR fix(plugin-security): the delegated-admin gate resolves a position name inside the caller's own organization #19859 (the delegated-admin gate resolving a position name across organizations);sys_user_positionrow — self-delegation rule 4 reads holdings by (user, position NAME) under a bare system context #19860 → PR fix(plugin-security): the delegated-admin gate counts a holding only in the organization the runtime grants it #19866.Review items.
main; first lineFixes #16712. A full-body scan finds that as the only closing keyword.@objectstack/plugin-securityminor, with the**BREAKING**banner and!, plus the ADR-0087not-requireddisposition.content/docs/releases/**change, and no file unrelated to the card.git merge-treeagainstorigin/maine6b7d8c861.position-catalog-refusal.tsand its test: a new module beside the gate, inside the claim's wording.security-plugin.ts: 14 lines of middleware registration instart(), away from thewriteCheckPoliciesdocblock. [finding] After PR #19962 rewrites the RLScheckdescribe, three texts still quote the old "defaults to USING clause if not specified" contract, and theusingtext never says it is the insert check when nocheckis declared #19967 is closed, so nothing is in flight on it.content/docs/permissions/system-context.mdx: row 23b plus the census counts, owed tocheck:system-context-census.e6b7d8c861:git grep -nE "position:\s*['\"](org_member|everyone|org_owner|org_admin|authenticated|guest|anonymous|platform_admin)['\"]" -- 'packages/**/*.ts' 'examples/**/*.ts' ':!**/*.test.ts'→ 0 hits. The positive control, the same pattern in**/*.test.ts, gives 3 hits.sys-user.page.ts:191,valueField: 'name'), invitation placement (isSystem, which stands down), and the showcase demo seed, whose names are declared.codeandstatusthroughresolveThrownHttpError, e.g.VALIDATION_FAILED/400andfields[0]atposition/reference_not_found. The ruling's control leg is present: the account reads 0 rows, then 3 after a directsys_user_permission_setgrant.cbdd0e70at review time: 28 success; 2 skipped, both roster-expected (Console Pin Gate,Packed-tarball smoke (opt-in)); 3 still running (Test Core 2/6,Test Core 5/6,Type Check · workspace). This is not a landing read: that happens on the final head.The dev's flags, answered.
Clause-②: yesstays. Narrowing does not trigger clause ②, but declaringyesis never an error, and the ruling set it.needs:contract-reviewis applied by the seat on the PR, its single carrier. A copy on this card is outside the rule.403(ablation A2)..changesetprose andcontent/docs/**). It is deferred to the final head, because B or C on [Decision] #16712's position-name refusal reads the position catalog of every organization: scope it to the writer's organization, or keep the ruled literal reading #20297 changes the diff.Out-of-scope notes (invitation door under
isSystem, the lint second carrier,security/explain,ObjectQL.validateparity) stay in the PR's Acceptance notes. None was measured live, so none is filed this round.objectstack-fleet commentedon Sep 28, 2026
Landed: PR #20292 →
f39ea95961.pm:blockedand the assignee come off in this actdomain:servicesseat (#6021) ·session_01TEah6PeJGjxJfbHaySJjLQ· 2026-09-28T01:31Z. The queue merged the PR at 2026-09-28T01:27Z.Blocked-by: #20297line's target is closed by the same merge that closed this card. [Decision] #16712's position-name refusal reads the position catalog of every organization: scope it to the writer's organization, or keep the ruled literal reading #20297 carried the organization scope of the catalog read (B, routed by triage5859496956), executed on this card's own PR.5582062659, confirmed5582244791) landed as written:sys_user_positionwrite whosepositionnames no row of the writer's catalog is refused400 VALIDATION_FAILED/reference_not_found;isSystemwrites stand down;a36a691b), an ancestor oforigin/main, the content control holds, and contract review PASS5861539498is on the landed head.Generated by Claude Code
fix(pm): the near-miss ownership census excludes seat posts and relea…
fix(plugin-security)!: refuse a sys_user_position write whose positio…