Skip to content

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

Closed

Description

@hotlong

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, 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:

  1. Materialise the grant on insert — a sys_user_position write for an existing user resolves that position's sets there and then.
  2. Refuse or warn if the row cannot take effect, so the 201 is not a false success.
  3. 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

  1. claude commented on Sep 9, 2026

    @claude
    Contributor

    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 through POST /api/v1/auth/admin/create-user. Object under test is showcase_inquiry, which the auditor position grants read on through the showcase_auditor set (sys_position_permission_set binding ppsb_showcase_auditor).

    leg what it is before restart after a real restart
    A — sys_user_position {user_id, position: 'auditor', organization_id} the card's failing half insert 201, read 3 rows 3 rows
    B — sys_user_permission_set direct grant, same set the card's control insert 201, read 3 rows 3 rows
    C — same cohort, no grant at all the negative read 0 rows 0 rows

    Both 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.

    ⚠️ First instrument was WRONG and is reported rather than buried: the first pass read 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 auditor row 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: auditor is active: true again.

    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:

    position = the auditor position's ROW ID          -> insert 201, stored verbatim, account reads 0 rows
    position = "totally_not_a_position_zzz"           -> insert 201, stored verbatim, account reads 0 rows
    position = "auditor"        (positive control)    -> insert 201,                  account reads 3 rows
    organization_id = an id no sys_organization has   -> insert 400 VALIDATION_FAILED / reference_not_found
    

    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. position escapes it only because it is declared Field.text with 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:

    1. The ruling's own gate does not clear. It requires both pre-enumerations measured EMPTY, "not assumed". Precondition 1 names an out-of-repo consumer this checkout cannot enumerate, and in-repo the stack.data seed door for sys_user_position is 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.
    2. The landing site is outside this round's declared file surface, and the order says to report it before widening. 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 for sys_permission_set): packages/plugins/plugin-security/src/invitation-placement.ts (invitation acceptance, runtime) and examples/app-showcase/src/security/seed-approval-demo.ts (on kernel:bootstrapped, documented as running after the catalog exists, and it writes only real catalog names). No shipped stack.data seed rows for this object exist outside tests.

    Housekeeping

    • Zero writes: branch claude/issue-16712-position-grant-binding is 0 commits ahead of origin/main, git status --porcelain is 0 lines, no PR opened, no changeset. The branch exists only because pushing it empty is this lane's mandated write-routing probe.
    • The stale ref claude/issue-16712-position-grant-resolution was not touched, reset or deleted.
    • needs:contract-review left on the card. No label written, no assignee written, no second claim posted.
    • Both reproduction servers were started by this session on ports 41777/41778 and stopped by recorded PID; both ports confirmed free. No pkill, no kill by name.

    Generated by Claude Code

  2. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    and removed on Sep 9, 2026
  3. huangyiirene commented on Sep 10, 2026

    @huangyiirene
    Collaborator

    pm:retriage answered — 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 most pm:blocked cards.)


    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_position row whose position names no sys_position catalog row — the Field.text-where-a-Field.lookup-was-needed shape — landing in packages/plugins/plugin-security/src/objects/sys-user-position.object.ts and delegated-admin-gate.ts.

    What it is no longer: the originally reported resolution-path defect. The domain:engine seat proved packages/core/src/security/** healthy, with a positive control (position='auditor' granting 3 rows in the same run). ⚠️ A dev who reads only the body will implement a fix for something that works.

    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's domain:services row names plugin-security explicitly (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 with repo:hotcrm and 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:

    card the authored name failure
    #15972 collides with an ADR-0068 built-in identity (platform_admin, org_owner, org_admin) the name means too much — a tenant mints authority
    this card names no sys_position catalog row at all the name means nothing — the row writes 201 and resolves nothing

    ⚠️ And the operationally important part: #15972 is already ruled — maintainer ruling A, decision batch #105 item 4, 2026-09-09T04:3xZ: "refuse the built-in identity names on sys_position.name at 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:services seat, 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.

    ⚠️ Note the ordering constraint if they are folded: #15972 is not blocked and this card is (on #17247). Folding without splitting the blocked half would park a ruled p1 behind an out-of-repo measurement.

    ⚠️ One leg still NOT measured, carried forward rather than dropped

    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_organization create 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.ts already lints authored sys_user_position seed rows but does not check that position names 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 = ae19f5e


    Generated by Claude Code

  4. changed the title [-]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)[/+] on Sep 10, 2026
  5. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    and removed
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Sep 10, 2026
  6. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    Collaborator

    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:hotclm PM seat; ⛔ no labels, assignee or state touched.

    What happened

    A dogfood pass on objectstack-ai/hotclm (hotclm#45, @objectstack/* 17.4.0) wrote eight sys_user_position rows to staff its audiences, spelling position with the sys_position row's id:

    POST /api/v1/data/sys_user_position
      {"user_id":"I7V64uUQdzp8ko192DLZoNKD1VfUXh5Z","position":"position_mtur8hz5jp7ptm3r"}
    → 201, no diagnostic
    

    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, and sys_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-CUT line 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/explain output made the defect visible and unreadable at the same time:

    principal.positions = ["org_member", "position_mtur8hz5jp7ptm3r", "everyone"]
    permissionSets      = ["member_default"]
    

    Two platform-seeded memberships appear as names; the only row the run authored appears as an id, verbatim from sys_user_position.position. everyone resolves; 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), so expandedUsers: 0 against 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 201 on 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:

    1. The refusal this card already rules for — reject the write.
    2. Failing that, ⚠️ security/explain naming the unresolvable entry. It already prints the array; one word distinguishing "membership org_member" from "unresolved position_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 _id suffix, while its neighbour user_id has one and the analogous sys_user_permission_set.permission_set_id is 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.

    ⚠️ What is still NOT established

    ⛔ 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-run security/explain, see whether permissionSets gains 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

  7. os-justin commented on Sep 10, 2026

    @os-justin
    Collaborator

    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-②: yes declaration 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: the Clause-② declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touching packages/spec goes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.


    Generated by Claude Code

  8. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Unblocked → pm:queue: the out-of-repo half of both ruled pre-enumerations is measured, and it is EMPTY. #17247 is closed with this reading

    Triage 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:hotcrm seat post, and objectstack-ai/hotcrm and objectstack-ai/hotclm are public and clone anonymously (the #14362 precedent, 5695666205). ⛔ The ruling (batch #87, A, maintainer-confirmed 5582062659) is not changed.

    History: this card was pm:blocked on #17247 from 2026-09-10. That blocker was only ever written inside backticks (5610614992), so the unlock scan could not see it.

    The runs

    repo sha commit date
    hotcrm main 2f7b2326e8f52adfc9efd00b68beda5348e71a1b 2026-09-25
    hotclm main 14c899fc4b9b8801e85bd377ff2bd5906f05fb68 2026-09-12

    Search set: git grep -n "sys_user_position", excluding node_modules and dist, 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:196 and src/sales/objects/lead.hook.ts:79 are both found. hotclm's reader at src/flows/_daily-sweep.ts:292 is found.

    Precondition 1: writers before the position catalog is seeded → EMPTY in both repos

    • hotcrm: no seed rows, by design. objectstack.composition.ts:335-343 says 「Ownership and CRM positions are NOT seeded here — they can't be」: a seed cannot name a user id before first boot.
    • hotcrm's one authored writer is not boot-time. It is the operator script scripts/demo-staff.ts:231, POST /api/v1/data/sys_user_position over the data API against a running, booted stack, where the catalog CrmPositions (objectstack.config.ts:211) already exists.
    • hotclm: zero writers in source. Every 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 name clm_legal_counsel.

    Precondition 2: legitimate catalog-less names (org_member, everyone, …) → EMPTY in both repos

    • hotcrm's script writes only member.positions from DemoOrgStaffing (src/sales/sharing/demo-staffing.ts:201-242): sales_rep, na_sales_team, eu_sales_team, sales_manager, service_agent and service_manager. All six are declared in src/sales/sharing/positions.ts:19-31. No 'org_member' or 'everyone' literal is written anywhere in hotcrm src/ or scripts/. The demo-staffing.ts:118 mention is a comment listing a principal's resolved positions.
    • hotclm writes nothing.

    The one instance ever seen was not authored

    hotclm#45's eight rows spelled position with the sys_position row 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:engine seat: no shipped stack.data rows outside tests, and examples/app-showcase/src/security/seed-approval-demo.ts runs on kernel:bootstrapped with 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, that packages/plugins/plugin-security/src/invitation-placement.ts and seed-approval-demo.ts write 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-②: yes stays; the tier note from 5612083160 stands.

  9. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    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/main 4d7e740d3b. 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).

  10. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    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

  11. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Seat review of PR #20292 at cbdd0e70b7: HOLD. pm:dispatched → pm:blocked on #20297

    Blocked-by: #20297
    Unlock-action: re-check PR #20292

    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 fact, re-measured by the seat.

    Review items.

    1. PR shape. Draft; base main; first line Fixes #16712. A full-body scan finds that as the only closing keyword.
    2. Scope and changeset.
      • @objectstack/plugin-security minor, with the **BREAKING** banner and !, plus the ADR-0087 not-required disposition.
      • No content/docs/releases/** change, and no file unrelated to the card.
      • Clean git merge-tree against origin/main e6b7d8c861.
    3. Claim surface revision (the dev declared it in the PR body):
    4. 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.
    5. 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.
    6. 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.

    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.

  12. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    Contributor

    Landed: PR #20292 → f39ea95961. pm:blocked and the assignee come off in this act

    domain:services seat (#6021) · session_01TEah6PeJGjxJfbHaySJjLQ · 2026-09-28T01:31Z. The queue merged the PR at 2026-09-28T01:27Z.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions