From 6a86e41b1b0e717266b7f71cd8fe9db4c116bdcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:40:48 +0000 Subject: [PATCH 1/2] docs(readme): state the real milestone, and that positions bind by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line still read `M0 — scaffold and configuration domain` on a tree that carries M1-M3: the eleven objects, the permission layer, intake, the approval ladder, signing and execution formalities, the post-signature reminder layer, four datasets, three dashboards and both locale bundles. A first-time reader was told this is a scaffold and then found 820 seeded rows. Replaced with one line naming the current state plus a pointer to DESIGN.md 11 and the three remaining cards, rather than a checklist that drifts the same way. The setup section named the seven positions but never said how the assignment binds. `sys_user_position.position` is a plain text column holding a `sys_position.name` - the one field on the row without an `_id` suffix, while `user_id` is a real lookup and `sys_user_permission_set.permission_set_id` is genuinely id-typed. Anyone scripting the setup reaches for an id by analogy, gets `201` with no diagnostic, and ends up with an account holding no permission set and empty navigation. Documented the wire shape, the set a name-spelled row actually grants, the `explain` call that reads it back, and the silent id acceptance (upstream objectstack-ai/objectstack#16712, open). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR --- README.md | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17e1dc1..6f503b6 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,11 @@ Self-serve intake, a clause playbook, a data-driven approval matrix, e-signature **基于 ObjectStack 的合同全生命周期管理。** 业务自助发起、条款库与偏离、审批矩阵、电子签与执行形式、履约义务、收付款计划 —— 全部是类型化元数据。 -> Status: **M0 — scaffold and configuration domain.** See [DESIGN.md](./DESIGN.md) for the model and -> [docs/backlog](./docs/backlog/README.md) for what is being built next. Sibling app of +> Status: **M1–M3 are in.** The model and the permission layer, intake, the approval ladder, signing +> and execution formalities, the post-signature reminder layer, the analytics and both locale bundles +> all land in this tree. **M4 is what remains** — e-signature, the HotCRM hand-off, the AI skills and +> the release: [DESIGN.md](./DESIGN.md) §11 is the milestone table, and cards 12 · 13 · 14 in +> [docs/backlog](./docs/backlog/README.md) are what is left in it. Sibling app of > [HotCRM](https://github.com/objectstack-ai/hotcrm): commercial terms stay in the CRM, legal state lives here. ## What it is @@ -112,6 +115,39 @@ and `reviewer` has to: it is `required: true`, so a name that resolves to nothing takes the row with it, and the dev admin is the only account that exists while the seed runs. Reassign those two once the real accounts are there. +### Assigning a position from a script + +Clicking through Setup → Users needs nothing more than the table above. Automating it does: the +assignment binds by the position's **name**, not by its id. `sys_user_position.position` is a plain +text column carrying a `sys_position.name` — the one field on that row without an `_id` suffix, while +its neighbour `user_id` is a real lookup and the analogous +`sys_user_permission_set.permission_set_id` is genuinely id-typed. + +```http +POST /api/v1/data/sys_user_position +{ "user_id": "…", "position": "clm_legal_counsel" } +``` + +A name-spelled row grants that position's own permission set **and** `clm_requester`, because every +position binds that set too (`src/security/bind-position-sets.ts`) — a lawyer holds `clm_legal` and +`clm_requester` both. To read back what an account actually ended up with: + +```http +POST /api/v1/security/explain +{ "userId": "…", "object": "clm_contract", "operation": "read" } +``` + +`object` and `operation` are both required there — a body carrying only `userId` answers +`400 VALIDATION_FAILED`. + +**An id in the `position` field is accepted, not refused.** Nothing resolves that column on write, so +a row spelled with the position's id is stored verbatim, matches no position and grants no permission +set — and the write still answers `201`, with no diagnostic anywhere to say so. The account lands in +the state the dev admin is in above: signed in, holding no `clm_*` set, served `navigation: []` and +empty lists, with nothing in the response that created it pointing at why. The silent acceptance is +the platform's — `objectstack-ai/objectstack#16712`, still open — so until that lands, the spelling is +the whole defence. + ### What the seeded rows do NOT carry, and why The demo is loaded as **system data**, not driven through the flows. Two From 0a9c3401ba37c6f2a06d8d086ffd22c724f20a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:53:49 +0000 Subject: [PATCH 2/2] docs(readme): drop the false `_id`-suffix tell from the position note The subsection claimed `position` was "the one field on that row without an `_id` suffix". Enumerating `SysUserPosition` in @objectstack/plugin-security@17.4.0 shows fourteen fields, of which only three carry the suffix - and `granted_by`, `delegated_from` and `certified_by` are all `Field.lookup("sys_user")` with no suffix at all. The sentence taught a tell that is falsified three times over on its own object: an operator generalising it onto `granted_by` would spell a name into a genuine lookup, the mirror image of the bug the subsection exists to prevent. Rebuilt the paragraph on the evidence that does hold - the field's own declaration, `text` with the description "Position machine name (references sys_position.name)" - and says outright that the field name tells you nothing here because the object mixes both kinds. The `sys_user_permission_set.permission_set_id` contrast stays: that one really is id-typed, which is what makes the wrong guess tempting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6f503b6..1f30912 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,14 @@ while the seed runs. Reassign those two once the real accounts are there. ### Assigning a position from a script Clicking through Setup → Users needs nothing more than the table above. Automating it does: the -assignment binds by the position's **name**, not by its id. `sys_user_position.position` is a plain -text column carrying a `sys_position.name` — the one field on that row without an `_id` suffix, while -its neighbour `user_id` is a real lookup and the analogous -`sys_user_permission_set.permission_set_id` is genuinely id-typed. +assignment binds by the position's **name**, not by its id. Read that off the declaration, not off +the field name — `sys_user_position.position` is declared `text`, and its own description says what +it carries: *"Position machine name (references `sys_position.name`)."* The field name tells you +nothing here, because this object mixes both kinds freely: `user_id`, `granted_by`, `delegated_from` +and `certified_by` are all genuine lookups, and only three of its fourteen fields carry an `_id` +suffix at all. What makes the analogy tempting is the neighbouring API — +`sys_user_permission_set.permission_set_id` really is id-typed — so the one you would reach for by +analogy takes an id where this one does not. ```http POST /api/v1/data/sys_user_position