You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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, position and organization_id all 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_position write 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.
Context I should have had before filing, found in a sibling app repo an hour later. It narrows this card and may reframe it — posting rather than letting the original stand unqualified.
objectstack-ai/hotcrm hit the neighbouring version of this and solved it in scripts/demo-staff.ts (its #640). That script's header documents a mechanism this card's report did not name:
RE-EVALUATE every active sharing rule. This step is not optional and is the reason staffing alone was never enough: plugin-sharing materialises grants from a record-write hook that returns early on isSystem writes, and every seeded row is written with isSystem: true. So the seeded accounts carry no grants no matter who holds a position, until a rule is re-evaluated (boot backfill does it too — this just avoids the restart).
Two things follow, and they pull in opposite directions:
1. Part of what I described is documented behaviour with a known remedy, not a defect. Row-level access via sharing rules does not materialise for rows written with isSystem: true until a rule is re-evaluated. HotCRM's answer is an explicit re-evaluation step, and it reports that the boot backfill does it too.
2. That may not be what this card measured, and the distinction is the whole question. The report here is that the account is served an empty navigation and reads nothing. Navigation gating is driven by systemPermissions coming from permission sets — the position→set link — not by sharing grants, which govern which rows an already-permitted object read returns. Those look like two different mechanisms, and the note above only covers the second.
Also unexplained by the note: the report states the condition survived a restart, whereas the note says a boot backfill re-evaluates. Either the restart did not trigger what HotCRM's header describes, or the two halves fail independently, or the report's restart observation is wrong.
⇒ The card as filed conflates what may be two mechanisms. What it can still stand on: an operator following the documented "create the account, assign the position in Setup" path gets a 201 and an account that sees nothing, with no signal distinguishing "correctly staffed" from "silently unstaffed" — and if the remedy is "re-evaluate a sharing rule" or "restart", neither is discoverable from that 201.
What would help most from your side: say which of the two links is expected to materialise on a sys_user_position insert for an existing user, and which needs an explicit re-evaluation. If it is only the sharing half and the permission-set half should be immediate, this card is still a defect. If neither is immediate by design, this is a documentation and error-signal card, not a behaviour one — still worth closing, but differently.
⛔ I have not re-measured the two halves apart. That separation is the first thing this card should do, and I would rather say so than let a filing imply a distinction it did not test.
⇒ position → 权限集是请求时现算的,中间没有任何 bootstrap 物化步骤。所以卡面正文那句推断(「whatever materialises position→permission-set grants runs at bootstrap and has no path for a user added later」)对权限集这一半不成立。缓存也不是解释:resolve-user-grants-cache.ts 的开关 OS_AUTHZ_GRANTS_CACHE_TTL_MS默认 0,且其 docblock 明写 0 是一条真实路径 —— 什么都不接、什么都不存。有效期窗口同样不是:grant-validity.ts 的 isGrantActive 对 valid_from / valid_until均缺省的行返回 true(「Null/absent bounds mean unbounded」)。
不是 p1:有可用规避(直接授 sys_user_permission_set,卡面的对照腿已证明它立刻生效),⛔ 且最关键的一轴按作者自己的话「I have not re-measured the two halves apart」尚未分离。按未分离的测量去取两种假设里更坏的那个来定级,是把不确定性算成严重性。
Ruling recorded — A on the narrow predicate, gated on two pre-enumerations; fallback B; the return-to-reporter action runs in parallel (director seat, decision batch #87, 2026-09-08)
Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH), standing delegation given at 2026-09-08T08:3xZ, verbatim: 「继续决策」 — rule per the presented recommendation; every ruling in this batch is reversible by the maintainer. Recommendation adopted: the four-axis analysis at 5580789284 (recommend A, fallback B, ⛔ C/D not alone, D's return action in parallel) plus the dispatch seat's second pre-enumeration in the same comment.
Ruled. The card's option 2 (refuse-or-warn) executes as A: a sys_user_position write whose position value names no sys_position row at all is refused, with a closed-enumeration ADR-0112 error code naming the value and the remedy. ⛔ The predicate is exactly "no catalog row carries this name" — never "this assignment cannot take effect": ADR-0049 keeps a deactivated position's assignment while it stops granting (shape E stays accepted, the fossil wins). ⛔ E (turn position into a lookup) is out of this card — a stored-shape migration, its own card if ever.
Two preconditions, both must be measured EMPTY before A lands, ⛔ not assumed:
callers that write sys_user_position before the position catalog is seeded (seed-order-sensitive scripts — in-repo, and the hotcrm/hotclm consumer);
callers that legitimately write a membership-derived or catalog-less name (org_member, everyone, …) into sys_user_position.position.
Either list non-empty ⇒ ⛔ do not self-adjust: bring the list back to this card. The fallback is B (same predicate, warn: server log line plus a response field), and the maintainer then picks between A-with-caller-repair and B.
In parallel, whichever branch: return to the reporter for the failing account's live resolveUserAuthzGrants envelope, read against the shapes table at 5580698784 — with the F/I blind spot stated: one extra read of sys_position / sys_position_permission_set is needed to split "row owned by another org" from "no permission-set binding". Tell the reporter the quoted demo-staff.ts header is stale on 17.3.0 (#13533 reversed the isSystem skip). Ask which of hotclm / hotcrm is real; ⛔ do not guess.
Grade stays priority:p2 (the p1 trigger measured as not firing); lane domain:engine; assignee kept.
Execution (per the standing rules, not re-decided here):Clause-②: yes — a new refusal on a published apiEnabled write path ⇒ needs:contract-review on the PR and on this card; changeset minor for the package that gains the error code, **BREAKING** banner plus ADR-0087 disposition graded against the batch #35 WHICH LEVEL text. The regression test keeps the control leg (direct sys_user_permission_set grant, same account, same session) — the two routes are measured non-equivalent, and without that leg "reads nothing" and "permission set misconfigured" collapse into one observation again.
Maintainer confirmation of the ruling above (5582062659). Provenance: maintainer, live PM chat with the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-08T08:5xZ, verbatim: 「批 #87 同意」. The ruling is now the maintainer's, not a delegated one; the two pre-enumerations and the parallel return-to-reporter stand as written.
File surface: packages/core/src/security/** — the position→permission-set resolution path (resolve-authz-context.ts, resolve-user-grants-cache.ts, grant-validity.ts are the measured candidates) + tests + .changeset/*.md. ⚠️ Region-level: the landing site is a measurement this round takes. Report the landing site before widening.
⚠️The reproduction in this card is second-hand, by its own disclosure: 「I did not myself re-drive the failing half, and I say so rather than implying otherwise.」 ⇒ Reproducing it is this round's first deliverable, before any fix. If it does not reproduce on a current head, premise_still_valid: false with zero writes is a first-class delivery, not a failure.
⚠️ A stale ref claude/issue-16712-position-grant-resolution exists on the remote. Measured at 2026-09-09T16:47Z: 0 commits ahead of origin/main, an ancestor of it, no PR, no files changed — it carries nothing. ⛔ Do not touch, reset or delete it; this claim uses a different name so nothing needs rewriting.
Surface measured on origin/mainfd5cff209: sys_user_position appears in 172 files under packages/**. Controls fired — sys_user_permission_set186 files, sys_user_position_ZZZ0. ⚠️ An earlier pathspec of packages/*/src returned zero and was simply wrong; ⛔ do not trust a narrow pathspec without a control.
Serial constraints cleared: packages/core/src/security/** is free of in-flight work — PR #17101 fenced security/index.ts and landed. The open PRs fence packages/runtime/src/** (#17137), packages/objectql + packages/spec + plugin-auth (#17195), packages/formula + packages/lint/src/flow-variable-scope* (#17133), packages/metadata* non-migration files (#17198) — all disjoint from this surface.
Claim: PM loop round 1
Session: session_01TEah6PeJGjxJfbHaySJjLQ
Account: os-litant (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-16712-position-catalog-refusal
Worktree: objectstack-issue-16712
Domain: domain:services
Seat: domain:services#1
Ruling-ref: 5582062659
File surface: the sys_user_position WRITE path in packages/plugins/plugin-security/src/: objects/sys-user-position.object.ts (read; the position field 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) under packages/plugins/plugin-security/src/, and .changeset/16712-*.md. examples/app-showcase/src/security/seed-approval-demo.ts is read only, for the census. ⛔ No packages/spec source: if the refusal needs a new error code rather than an existing one, stop and report the fork. ⛔ No packages/core/src/security/** (the resolution path, proven healthy on this card). ⛔ Not the writeCheckPolicies docblock in security-plugin.ts (#19967, text-only, in flight). ⛔ Not packages/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 of sys-user-position.object.ts, delegated-admin-gate.ts, invitation-placement.ts, seed-approval-demo.ts or validate-security-posture.ts. Of the open pm:dispatched claims, two name plugin-security, and both are disjoint from this surface: #19967 (a security-plugin.ts docblock 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-day plugin-security commits on main: a9fb83ef (#20228) and 0d3ec471 (#20243), both outside this surface; the dispatch carries the churn line. Stale remote heads claude/issue-16712-position-grant-binding and -resolution carry nothing, per 5605500868; ⛔ they are not touched.
Clause-②: yes as ruled (5582062659, batch #87, confirmed 5582244791): a new refusal on a published write path. The unlock is the triage reading 5857658468: 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).
{
"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"
]
}
domain:services seat (#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.
The delivery executes ruling A (5582062659, confirmed 5582244791) as worded.
content/docs/permissions/system-context.mdx: row 23b plus the census counts, owed to check:system-context-census.
All three are recorded here as the revised surface.
Stop condition (precondition 2 in-repo). EMPTY, re-measured on e6b7d8c861:
Command: 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.
Non-test writers: the platform picker (sys-user.page.ts:191, valueField: 'name'), invitation placement (isSystem, which stands down), and the showcase demo seed, whose names are declared.
Rejection pins. They assert code and status through resolveThrownHttpError, e.g. VALIDATION_FAILED / 400 and fields[0] at position / reference_not_found. The ruling's control leg is present: the account reads 0 rows, then 3 after a direct sys_user_permission_set grant.
CI on cbdd0e70 at 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-②: yes stays. Narrowing does not trigger clause ②, but declaring yes is never an error, and the ruling set it.
needs:contract-review is applied by the seat on the PR, its single carrier. A copy on this card is outside the rule.
Landing after the security middleware is accepted. It keeps an unauthorized caller on 403 (ablation A2).
Out-of-scope notes (invitation door under isSystem, the lint second carrier, security/explain, ObjectQL.validate parity) stay in the PR's Acceptance notes. None was measured live, so none is filed this round.
Activity
hotlong commentedon Sep 8, 2026
Context I should have had before filing, found in a sibling app repo an hour later. It narrows this card and may reframe it — posting rather than letting the original stand unqualified.
objectstack-ai/hotcrmhit the neighbouring version of this and solved it inscripts/demo-staff.ts(its #640). That script's header documents a mechanism this card's report did not name:Two things follow, and they pull in opposite directions:
1. Part of what I described is documented behaviour with a known remedy, not a defect. Row-level access via sharing rules does not materialise for rows written with
isSystem: trueuntil a rule is re-evaluated. HotCRM's answer is an explicit re-evaluation step, and it reports that the boot backfill does it too.2. That may not be what this card measured, and the distinction is the whole question. The report here is that the account is served an empty navigation and reads nothing. Navigation gating is driven by
systemPermissionscoming from permission sets — the position→set link — not by sharing grants, which govern which rows an already-permitted object read returns. Those look like two different mechanisms, and the note above only covers the second.Also unexplained by the note: the report states the condition survived a restart, whereas the note says a boot backfill re-evaluates. Either the restart did not trigger what HotCRM's header describes, or the two halves fail independently, or the report's restart observation is wrong.
⇒ The card as filed conflates what may be two mechanisms. What it can still stand on: an operator following the documented "create the account, assign the position in Setup" path gets a 201 and an account that sees nothing, with no signal distinguishing "correctly staffed" from "silently unstaffed" — and if the remedy is "re-evaluate a sharing rule" or "restart", neither is discoverable from that 201.
What would help most from your side: say which of the two links is expected to materialise on a
sys_user_positioninsert for an existing user, and which needs an explicit re-evaluation. If it is only the sharing half and the permission-set half should be immediate, this card is still a defect. If neither is immediate by design, this is a documentation and error-signal card, not a behaviour one — still worth closing, but differently.⛔ I have not re-measured the two halves apart. That separation is the first thing this card should do, and I would rather say so than let a filing imply a distinction it did not test.
Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
分诊:
domain:engine/bug/priority:p2/pm:queue/ typeBug先回答上面那条追问 —— 「哪一条链应当在
sys_user_position插入后立即生效」。权限集那一半在代码里是逐请求解析的,不是 bootstrap 物化的。 读数如下(origin/main5e53d73d,packages/core/src/security/resolve-authz-context.ts):⇒ position → 权限集是请求时现算的,中间没有任何 bootstrap 物化步骤。所以卡面正文那句推断(「whatever materialises position→permission-set grants runs at bootstrap and has no path for a user added later」)对权限集这一半不成立。缓存也不是解释:
resolve-user-grants-cache.ts的开关OS_AUTHZ_GRANTS_CACHE_TTL_MS默认0,且其 docblock 明写0是一条真实路径 —— 什么都不接、什么都不存。有效期窗口同样不是:grant-validity.ts的isGrantActive对valid_from/valid_until均缺省的行返回 true(「Null/absent bounds mean unbounded」)。⇒ 追问里那个二分的答案是:权限集这一半应当立即生效。既然实测不生效,那么它就落在你说的第一种情形 —— 这仍然是一张行为缺陷卡,不是文档/信号卡。
但故障点因此挪了位置,且挪到了可以精确定位的地方
6a那一段里有三处会让链静默断在中途,且三处都会产出你观察到的现象(空导航 + 读不到行),彼此从外面看完全一样:grants.positions里根本没有那个名字 ——:724的读或:801的org && tenantId && org !== tenantId把行过滤掉了(插入时给的organization_id与登录后的 active org 不一致)。sys_position查不到行 ——:851这一读按tenantId作了租户限定(The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 修的跨租户串味)。若该 position 的sys_position行不在调用者的组织里,positionIds为空 ⇒ 一个集合都拿不到。isRowActive为假的名字连同它的集合一起被丢弃,而且名字本身也从positions里摘掉。另外注意
sys_user_position.position是按名字去 joinsys_position.name的({ name: { $in: grants.positions } })。复现步骤里 POST 的是{user_id, position, organization_id}—— 若那里放的是 id 而不是 name,链在第 1 步就断了,且照样回 201。⇒ 这张卡的第一步不是修,是把
resolveUserAuthzGrants对那个失败账号的返回信封打出来。 只要看grants.positions里有没有那个名字,上面三条就一刀切开了:有名字 ⇒ 断在sys_position/ 停用 / 结集;没名字 ⇒ 断在组织匹配或 name/id 之误。这也顺带把追问里"两个机制"的疑问彻底分开 —— 共享规则那一半(isSystem写入不触发物化)根本不进这条路径。priority:p2,以及升 p1 的触发条件不是 p3:一个已文档化的一等操作动作(在 Setup 里给账号指派岗位,ADR-0090 与
DESIGN.md§10 都这么教)返回 201 而毫无效果,且正确配置的账号与静默未配置的账号从外面看是同一个观察 —— 没有任何信号能分开它们。这一条无论最终诊断落在哪一半都成立,卡面自己也守住了这一条。不是 p1:有可用规避(直接授
sys_user_permission_set,卡面的对照腿已证明它立刻生效),⛔ 且最关键的一轴按作者自己的话「I have not re-measured the two halves apart」尚未分离。按未分离的测量去取两种假设里更坏的那个来定级,是把不确定性算成严重性。升级触发条件(写明以便不必回来问):一旦上述信封读数显示权限集这一半确实断了(即
grants.positions含该名字却拿不到集合,或组织匹配把合法行滤掉),立即升priority:p1—— 那时它就是"平台主用的授权路径在标准操作下静默失效",且规避方案(直接授集合)并不等价:卡面末尾已指出,直接授集合的账号拿不到按岗位授予的共享规则,行数天然更低。⇒ 规避会静默改变安全姿态,这样的规避不能用来压级别。车道与再定向条件
domain:engine—— 第一步的测量与最可能的故障点都在packages/core/src/security/(车道表packages/core在 engine 行)。再定向触发条件:若分离测量显示权限集这一半是好的、真正断的是共享规则那一半(
plugin-sharing从记录写钩子物化,而isSystem: true的写入提前返回),⛔ 不要在 engine 席硬修 —— 改标domain:services交出去,并在卡上写明分离读数。验收口径(承接 PR 请照抄进
## 验收备注)resolveUserAuthzGrants的信封,贴出grants.positions与解析出的权限集 id。⛔ 在这张读数出来之前不要改任何代码。sys_user_permission_set立刻可读 —— 这条是卡面证据的支点,回归测试里要有它,否则"账号读不到"与"权限集配错了"又会混在一起。objectstack-ai/hotclm,与文中同时提到的objectstack-ai/hotcrm只差一个字母。⛔ 承接前先确认哪个是真的 —— 顺着错仓名去找 chore(deps)(deps-dev): bump @vitest/coverage-v8 from 2.1.9 to 4.0.17 #29 / feat: implement browser language detection and auto-redirect for homepage #30 会得到一个"找不到",而找不到和"不存在"在这里看起来一样。本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱:不迁移存量数据形状;修的方向是让已文档化的行为兑现或让失败可见,不删除任何已发布能力。
Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Ruling recorded — A on the narrow predicate, gated on two pre-enumerations; fallback B; the return-to-reporter action runs in parallel (director seat, decision batch #87, 2026-09-08)
Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (
session_01TezFG8ZMrNH6n5VTNpPpdH), standing delegation given at 2026-09-08T08:3xZ, verbatim: 「继续决策」 — rule per the presented recommendation; every ruling in this batch is reversible by the maintainer. Recommendation adopted: the four-axis analysis at 5580789284 (recommend A, fallback B, ⛔ C/D not alone, D's return action in parallel) plus the dispatch seat's second pre-enumeration in the same comment.Ruled. The card's option 2 (refuse-or-warn) executes as A: a
sys_user_positionwrite whosepositionvalue names nosys_positionrow at all is refused, with a closed-enumeration ADR-0112 error code naming the value and the remedy. ⛔ The predicate is exactly "no catalog row carries this name" — never "this assignment cannot take effect": ADR-0049 keeps a deactivated position's assignment while it stops granting (shape E stays accepted, the fossil wins). ⛔ E (turnpositioninto a lookup) is out of this card — a stored-shape migration, its own card if ever.Two preconditions, both must be measured EMPTY before A lands, ⛔ not assumed:
sys_user_positionbefore the position catalog is seeded (seed-order-sensitive scripts — in-repo, and the hotcrm/hotclm consumer);org_member,everyone, …) intosys_user_position.position.Either list non-empty ⇒ ⛔ do not self-adjust: bring the list back to this card. The fallback is B (same predicate, warn: server log line plus a response field), and the maintainer then picks between A-with-caller-repair and B.
In parallel, whichever branch: return to the reporter for the failing account's live
resolveUserAuthzGrantsenvelope, read against the shapes table at 5580698784 — with the F/I blind spot stated: one extra read ofsys_position/sys_position_permission_setis needed to split "row owned by another org" from "no permission-set binding". Tell the reporter the quoteddemo-staff.tsheader is stale on 17.3.0 (#13533 reversed theisSystemskip). Ask which ofhotclm/hotcrmis real; ⛔ do not guess.Grade stays
priority:p2(the p1 trigger measured as not firing); lanedomain:engine; assignee kept.Execution (per the standing rules, not re-decided here):
Clause-②: yes— a new refusal on a publishedapiEnabledwrite path ⇒needs:contract-reviewon the PR and on this card; changesetminorfor the package that gains the error code,**BREAKING**banner plus ADR-0087 disposition graded against the batch #35 WHICH LEVEL text. The regression test keeps the control leg (directsys_user_permission_setgrant, same account, same session) — the two routes are measured non-equivalent, and without that leg "reads nothing" and "permission set misconfigured" collapse into one observation again.Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
Maintainer confirmation of the ruling above (5582062659). Provenance: maintainer, live PM chat with the director seat (
session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-08T08:5xZ, verbatim: 「批 #87 同意」. The ruling is now the maintainer's, not a delegated one; the two pre-enumerations and the parallel return-to-reporter stand as written.Generated by Claude Code
claude commentedon Sep 9, 2026
Claim: PM loop round 1
Session:
session_01XTBcV7zZHmokdyQgXjbyEUBranch:
claude/issue-16712-position-grant-bindingDomain:
domain:engineClause-②: yes
Container & model:
mode:subagent,model: opusFile surface:⚠️ Region-level: the landing site is a measurement this round takes. Report the landing site before widening.
packages/core/src/security/**— the position→permission-set resolution path (resolve-authz-context.ts,resolve-user-grants-cache.ts,grant-validity.tsare the measured candidates) + tests +.changeset/*.md.premise_still_valid: falsewith zero writes is a first-class delivery, not a failure.claude/issue-16712-position-grant-resolutionexists on the remote. Measured at 2026-09-09T16:47Z: 0 commits ahead oforigin/main, an ancestor of it, no PR, no files changed — it carries nothing. ⛔ Do not touch, reset or delete it; this claim uses a different name so nothing needs rewriting.Surface measured on⚠️ An earlier pathspec of
origin/mainfd5cff209:sys_user_positionappears in 172 files underpackages/**. Controls fired —sys_user_permission_set186 files,sys_user_position_ZZZ0.packages/*/srcreturned zero and was simply wrong; ⛔ do not trust a narrow pathspec without a control.Serial constraints cleared:
packages/core/src/security/**is free of in-flight work — PR #17101 fencedsecurity/index.tsand landed. The open PRs fencepackages/runtime/src/**(#17137),packages/objectql+packages/spec+plugin-auth(#17195),packages/formula+packages/lint/src/flow-variable-scope*(#17133),packages/metadata*non-migration files (#17198) — all disjoint from this surface.Generated by Claude Code
18 remaining items
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…