Skip to content

[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

Description

@objectstack-fleet

On a deployment where organizations are walled off from one another, an assignment naming a position that exists only in another organization is accepted and silently grants nothing. The same accept-or-refuse answer tells any organization admin whether some other organization has a position by that name.

Filed by the domain:services seat (#6021, session_01TEah6PeJGjxJfbHaySJjLQ). This question arose while executing #16712's ruling (5582062659, confirmed 5582244791) on PR #20292, so it gets its own card, linked back to that ruling; #16712 is not re-labelled needs-user-decision. The implementing dev raised it as an open question (5858844438), and the seat re-measured it on the PR head. ⛔ Not a claim.

Background

Governing text

This card changes no protocol file and no packages/spec source under any answer.

Premises, each with its re-check

  1. The PR reads the catalog without a tenant.
    • Re-check: git show <PR head>:packages/plugins/plugin-security/src/position-catalog-refusal.ts | grep -n "^const SYSTEM_CTX" → { isSystem: true } as const.
    • Control: grep -n "export async function namesWithoutCatalogRow" → 1 hit.
  2. The engine's own lookup probe keeps the tenant wall.
    • Re-check: git grep -n "cross-tenant existence" origin/main -- packages/objectql/src/engine.ts → 1 hit.
    • Control: git grep -c "assertReferencesResolve" origin/main -- packages/objectql/src/engine.ts → ≥1.
  3. The platform's own "Assign position" picker can only produce names the writer's organization can see.
    • packages/platform-objects/src/pages/sys-user.page.ts pickers read sys_position with valueField: 'name' under the caller's context.
    • Re-check: git grep -n "valueField: 'name'" origin/main -- packages/platform-objects/src/pages/sys-user.page.ts → 2 hits (:176 comment, :191 the picker).
  4. No non-test writer stores a name this refusal could newly refuse.
    • The in-repo census on 4d7e740d3b was EMPTY (dev report 5858844438).
    • The seat re-ran the catalog-less-name leg on 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. Control, the same pattern in **/*.test.ts → 3 hits.
    • Out-of-repo: hotcrm and hotclm are EMPTY (5857658468).

Question

On a walled deployment, a writer in organization A stores an assignment whose position name exists only in organization B's catalog. Does the platform accept it (201) or refuse it (400)?

Options

option what happens what a customer sees
A: keep the literal reading (as PR #20292 stands) The catalog is read across every organization; a name any organization carries is accepted. The assignment saves and grants nothing, silently. Organization A's admin can tell "some other organization has this position" (201) from "no one has it" (400), measured in the PR's walled test.
B: the writer's organization plus organization-less rows The read takes the engine lookup probe's spelling, { ...context, isSystem: true }. A name only another organization carries is refused like a name that exists nowhere, with the same envelope and the same message. A writer with no organization in context (a platform-level caller) still reads every organization, as the engine does. A loud 400 that names the fix; the two cases answer identically. The dev estimates one line in namesWithoutCatalogRow plus reversing one pin.
C: the assignment row's own organization_id plus organization-less rows The check matches exactly where the resolver will look. Same as B for an organization admin. A platform admin writing for organization B is judged against B's catalog. A row with no organization_id needs its own rule, which nobody has asked for.
  • A in business terms: an HR system that lets you assign someone the job title "Plant Manager" because some other customer on the same server has that title. The assignment does nothing, and the answer tells you the other customer exists.
  • B in business terms: a job title must exist in your company's title list. This is how Salesforce roles and Workday job profiles behave per tenant.
  • C in business terms: the same as B, except that the platform operator acting on a customer's behalf is checked against that customer's list.

四维分析(业务立场)

Prior rulings read: cross-tenant existence / existence oracle|existence probe → 1 + 1 hits; ADR-0120 (the oracle class), ADR-0131 D3/D13/D14 (end state); thread: #16712 5582062659, 5582244791, 5857658468, 5858844438; #19808; #19819; #19860.

推荐 B(回退项:A,仅当维护者认定裁决字面优先且接受该探测口;⛔ C 不单独推荐)。只看①选 B;②③④ 是否翻转:否。
置信缺口:

  • 本席未在真实隔离部署(PG + org-scoping 插件)上端到端测过。walled 读数来自 dev 在引擎层、sqlite 上的实测。
  • "消费方都是单组织形态"取自 dev 报告,本席未复测。
  • 未读 objectui"分配岗位"对话框在平台管理员跨组织操作时读的是哪个组织的目录。

Execution per answer

Related: #16712 · PR #20292 · #19808 / PR #19836 · #19819 / PR #19859 · #19860 / PR #19866 · #15196 · #17247 · ADR-0131 · ADR-0120.

Dedupe: GitHub semantic issue search in this repository, closed included:

Activity

  1. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold | 缺项 (no item assigns another organization's position name on a walled deployment) | P2

    Triage: routed out of the decision box — not a decision; precedent decides B. security · priority:p1 · domain:services · area:access · pm:queue

    Triage: lands in packages/plugins/plugin-security/src/position-catalog-refusal.ts (namesWithoutCatalogRow's SYSTEM_CTX), on PR #20292 (open) ⇒ domain:services. Rationale: reading the catalog under a bare { isSystem: true } spans every organization, which makes a cross-tenant existence oracle for organization admins. Priority rule 1 (security) ⇒ p1, the grade of #19808, the same class.

    Triage seat (objectstack-wide, seat post #6015) · session_01W89enF2dYV7K4N2Fbfj33f · 2026-09-27T20:20Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), #16712's ruling (5582062659 / 5582244791) as quoted, and the governing texts this card cites.

    Why this is not a decision. The #16712 ruling fixed the predicate (「no catalog row carries this name」) and did not say whose catalog. That gap is already filled, three times, by maintainer-grade security fixes of this exact shape:

    Option A re-creates the shape those three removed. C adds a rule for organization-less rows that nobody asked for. ⇒ B, the engine probe's spelling: the writer's organization plus organization-less rows. A caller with no organization in context still reads every organization, as the engine does. ⛔ This does not re-open the #16712 ruling. It applies the platform's standing tenancy rule to the ruling's predicate.

    Execution: in PR #20292, before it lands. The domain:services seat applies it and closes this card with Fixes #20297 beside #16712.

    1. SYSTEM_CTX becomes { ...context, isSystem: true } at the read.
    2. The walled test (position-catalog-refusal.test.ts:419) flips: a name only another organization carries is refused, with the same envelope and message as a name that exists nowhere.
    3. Keep the single-posture control, where every reading agrees.
    4. The system-context census re-anchors if lines move.
  2. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    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-20297
    Domain: domain:services
    Seat: domain:services#1
    Ruling-ref: 5859496956
    File surface: PR #20292's own surface, as a patch round on its head cbdd0e70b7:

    Triage routed this card out of the decision box, finding it not a decision because precedent decides B (5859496956): #19808, #19819 and #19860. It applies the engine lookup probe's { ...context, isSystem: true } to the #16712 ruling's predicate (5582062659, confirmed 5582244791), which it ⛔ does not re-open. Clause-②: yes stays, as ruled on #16712.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. Every single-posture catalog row has a null organization_id, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself.",
      "tests": "All at final head cb1d5be9 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 2914 passed (2914)'. Previous head: 2910; +4 new cases. (2) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. tsc --noEmit -p tsconfig.test.json --listFiles counts position-catalog-refusal.test.ts once. (3) position-catalog-refusal.test.ts: 18 passed; it was 14. The :419 pin is reversed; 4 cases are new: own-org control, function-level scoped/unscoped read, foreign-row-id vs nowhere-id, single-posture org-bound writer. Every refusal pin asserts code+status through resolveThrownHttpError. (4) Mechanism probe, run BEFORE the change at 75804ad6 (refusal module blob bfe123d631e8 == cbdd0e70's), from a scratch test file since deleted. Update-by-id with an unknown name: org_b row -> 403 PERMISSION_DENIED; nowhere id -> 403 PERMISSION_DENIED, same message. The same two with own-org name qa_a_own -> 403 and 403. Insert qa_b_only from org_a -> RESOLVED (201). No-tenant insert, and no-tenant predicate update -> 403 'no active organization'. organizationId-only insert -> 403. sys_position by name, counts for qa_b_only / qa_a_own / qa_auditor / nope: bare context 1/1/1/0; {...orgA, isSystem} 0/1/1/0; no tenant 1/1/1/0; organizationId-only 1/1/1/0. Single posture: both rows organization_id null, and qa_auditor visible with and without tenantId. (5) Ablations at cb1d5be9, via scripts/ablation-replace.mjs WRAP plus a shell trap restoring the absolute path from HEAD. Each leg printed 'ok mutation landed: anchor 1 -> 0' and 'ok restored: blob == HEAD ... git diff HEAD is empty', and the trap re-proved the blob. Subject reached through relative src imports, so no build or dist preflight applies. B1, name read back to bare SYSTEM_CTX: 2 failed / 16 passed. 'Error: expected the write to be refused, but it succeeded' (reversed pin), and 'expected [ nope_position ] to deeply equal [ qa_b_only, nope_position ]'. C1, hint read back to bare: 1 failed / 17 passed; received message names 'qa_b_only'. P1, catalog judged at the top of the security middleware: 3 failed / 15 passed. 'caller u_member: expected VALIDATION_FAILED to be PERMISSION_DENIED'; 'nope_position: expected [ VALIDATION_FAILED, 400 ] to deeply equal [ PERMISSION_DENIED, 403 ]'; the foreign-org wall. All three also ran at c8b533d2 with identical counts. (6) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at cb1d5be9, no paths: 5 paths vs merge base 7b1e4a487, 94 commands. The dispatch list had 93; the new one is pnpm check:dispatcher-error-vocabulary. All 94 were run on cb1d5be9, each exit captured after a single redirect: 94 exit 0. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN', a DERIVED zero, exit 0. check:skill-examples, check:dual-build-cjs-loads, check:i18n and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3); they are green after turbo build over ./packages/* (71/71). Also run: the 4 artifact-roster gates the derivation flags under this diff's directories, all exit 0 (check-changeset-fixed.mjs, check:authz-resolver, check:error-code-casing, check:filter-alias-parity). The diff-scoped node scripts/check-issue-citations.mjs --base 7b1e4a48 gave '6 citations, 6 resolves', exit 0. check:adr-0087-registration reads '[BREAKING+bang] not-required (no-migration-prescription)'. check:system-context-census: 'OK — 106 elevation read sites in 19 packages across 45 files, living in 89 symbol(s)'. (7) Lint narrowed and proven at cb1d5be9. eslint --print-config gives a config for the 3 .ts files and 'undefined' for the .md and .mdx. eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings. eslint.config.mjs has 0 'project:' keys, so it is not type-aware. (8) Declared to CI, not run locally: packages/qa/dogfood (single posture, catalog names) and the 6 path-scheduled CI jobs the derivation lists.",
      "mcp_calls": "0",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/20297/comments, the os-dev-report, via scripts/pm/post-stamped.mjs (fleet-write route). Plus git push of the branch, which is not REST: 4 pushes, cbdd0e70 -> 75804ad6 -> c8b533d2 -> 418b4411 -> cb1d5be9, all fast-forward. Reads were single-card or single-comment REST GETs and one GET of PR #20292.",
      "gates": "cb1d5be9: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0",
      "files_changed_this_round": [
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts",
        "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts",
        ".changeset/16712-position-catalog-refusal.md",
        "content/docs/permissions/system-context.mdx"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name"
      ],
      "pr_body_append": "## Patch round (#20297)\n\nExecutes the triage routing on #20297 (comment 5859496956), option **B**: the predicate the #16712 ruling fixed now reads the WRITER's catalog, meaning the writer's organization plus organization-less rows, in the engine lookup probe's spelling `{ ...context, isSystem: true }` (`assertReferencesResolve`, #19808). The ruling is not re-opened: a deactivated position is still a catalog row, so shape E stays accepted. Dispatch: PM session `session_01TEah6PeJGjxJfbHaySJjLQ`, claim 5859557977.\n\n**What changed** (head `cb1d5be9`)\n\n- `namesWithoutCatalogRow(deps, names, context)` and `idSpellingHints(deps, values, context)` now take the writer's context as a required parameter. They read `sys_position` under `catalogReadContext(context)`, which is `{ ...context, isSystem: true }`, and `assertPositionNamesCatalogRow` passes `opCtx.context`. `security-plugin.ts` is unchanged in this round.\n- The id hint's organization post-filter is removed. The hint now reads exactly what the check reads (see the C1 ablation below).\n- The by-id pre-image read stays a bare `{ isSystem: true }`. It is measured unreachable for a foreign row id, and that is pinned (below).\n- Module docblock: a new \"Whose catalog\" section states B and cites #20297 with the engine precedent (#19808, #19819, #19860). \"Where it runs\" no longer says authorization-first is what protects an unscoped read. Authorization-first itself stays (P1 below).\n- Changeset: the \"read across every organization\" line is gone. A \"Whose catalog\" paragraph says the catalog is the writer's organization's positions plus the organization-less ones, and that a name only another organization carries is refused like any unknown name. `minor`, `**BREAKING**`, `!`, `Clause-②: yes` and the ADR-0087 disposition are kept; `check:adr-0087-registration` reads `[BREAKING+bang] not-required (no-migration-prescription)`.\n- `system-context.mdx` row 23b now describes the scoped read.\n- `origin/main` `7b1e4a48` is merged in (merge commit `2f036209`). The only conflict was the census file-count row. `pnpm gen:system-context-census` then re-derived symbols 88 → 89 (`75804ad6`). The PR now reads `mergeable_state: clean`.\n\n**Mechanism readings.** These use the walled two-organization fixture: a real `ObjectQL` over SQLite, with the real `SecurityPlugin`. The \"before\" column was measured at `75804ad6`, whose refusal module is byte-identical to `cbdd0e70`'s; the \"after\" column is the pins at `cb1d5be9`.\n\n| write, from an `org_a` admin | before | after |\n| --- | --- | --- |\n| insert `position: 'qa_b_only'` (only `org_b` carries it) | 201 | 400 `VALIDATION_FAILED` / `reference_not_found`, message identical to the exists-nowhere one |\n| insert `position: 'nope_position'` (no organization carries it) | 400 | 400 |\n| update by id of an `org_b` row, unknown name | 403 `PERMISSION_DENIED` | 403 |\n| update by id of an id that exists nowhere, unknown name | 403 `PERMISSION_DENIED`, the same message | 403 |\n\n- **The pre-image read is never reached for a foreign id.** The security middleware answers a foreign id and a nonexistent id identically, before the refusal runs, so the pre-image read is left bare.\n- **Scoped vs bare lookup by name.** Looking up `sys_position` by name under `{ ...orgAdminContext, isSystem: true }` finds `qa_b_only` 0 times and `qa_a_own` once. Under a bare context, or a context with no tenant, each is found once.\n- **A writer with no organization never reaches the refusal on the isolated posture.** The security middleware answers `403` (\"no active organization\") first, for an insert and for a predicate update. So the \"reads every organization\" behaviour is pinned directly on `namesWithoutCatalogRow`.\n- **`single` posture.** Every catalog row has a null `organization_id`. The existing single-posture tests carry no tenant, so they never exercise the organization-less term. A new pin writes with `tenantId: 'org_a'`: `qa_auditor` is accepted and an unknown name is refused.\n\n**Tests** (`position-catalog-refusal.test.ts`, 14 → 18 cases)\n\n- **Reversed, not deleted.** A name only another organization carries is now refused `400 VALIDATION_FAILED`, `reference_not_found` at `position`. Its message is byte-identical to `positionNotInCatalogMessage('qa_b_only')`, and its envelope matches the exists-nowhere case key for key. A predicate update that sets that name is refused the same way.\n- **New.** The writer's own organization's name is accepted (the control). The scoped and unscoped readings are pinned on the function. A foreign row id is pinned to answer like a nonexistent id. An organization-bound writer on the single posture is pinned.\n- **Unchanged and green.** The control leg, shape E, the batch / multi / echo legs, the `isSystem` stand-down, 403-first, the foreign-organization wall and the id hint.\n\n**Ablations** (at `cb1d5be9`). Each ran through `scripts/ablation-replace.mjs` in WRAP mode plus a shell trap, and every restore was proven by blob == `HEAD` and an empty `git diff HEAD`. The subject resolves through relative `src/` imports, so no rebuild or `dist/` preflight applies.\n\n| ablation | mutation | result |\n| --- | --- | --- |\n| B1 | the catalog name read reverted to the bare `SYSTEM_CTX` | 2 red: the reversed pin (\"expected the write to be refused, but it succeeded\") and the organization-bound leg of the function pin |\n| C1 | the id-hint read reverted to the bare `SYSTEM_CTX` (post-filter already removed) | 1 red: the hint names `qa_b_only` |\n| P1 | the catalog judged at the top of the security middleware, before authorization | 3 red: 403-first, the foreign-row-id pin (`nope_position` answers 400 instead of 403), and the foreign-organization wall |\n\nC1 doubles as the post-filter measurement. With the scoped read and no post-filter, the full suite is green, and the hint pin fails only when the read itself is unscoped. So the post-filter is not kept.\n\n**Local verification at `cb1d5be9`**\n\n- `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 141 files / 2914 tests passed.\n- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The refusal test is in the `tsconfig.test.json` program (checked with `--listFiles`).\n- `node scripts/pm/dispatch-gates.mjs --commands` over the actual diff:\n  - 94 derived, 94 run, all exit 0;\n  - `--ran` reconciliation: \"94 run, 0 NOT-MEASURED (a DERIVED zero)\";\n  - this includes `check:query-options-erasure`, back to 236 sites: the pre-image pin's read had first been cast to `any`, and `cb1d5be9` types it.\n- Also run: the four artifact-roster gates flagged for this diff, and the diff-scoped `check-issue-citations.mjs` (6 citations, 6 resolve).\n- Lint, narrowed and proven:\n  - `eslint --print-config` puts the three `.ts` files in the population and the `.md` / `.mdx` files outside it;\n  - `eslint --no-inline-config --format json` counted 3 files, 0 errors and 0 warnings;\n  - `eslint.config.mjs` sets no `parserOptions.project`, so this diff cannot change a verdict on an untouched file.\n\n**Acceptance notes added this round**\n\n- **A context carrying only `organizationId`** (no `tenantId`) is not tenant-scoped by the engine's driver options. It reads every organization here (measured on the fixture) and, by the same driver-options reading, in the engine's own lookup probe (code reading). It cannot reach this refusal on the isolated posture, where it gets 403 first. Noted, not filed.\n- **Under the `group` posture** the scoped read follows the engine's membership union, so a name from another organization the writer belongs to is accepted. This is a code reading, not measured. Noted, not filed.\n"
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20292 at cb1d5be9d8 (patch round for #20297; closes #16712 and #20297)

    domain:services seat (#6021) · session_01TEah6PeJGjxJfbHaySJjLQ · 2026-09-27T21:45Z. Review of the report 5860074760 against GitHub and origin/main de091b50.

    Review items

    1. PR shape. Draft; base main. The head lines are now Fixes #16712 / Clause-②: yes / Fixes #20297: the seat replaced the stale Held-for: line. A full-body scan finds exactly those two closing keywords and one footer. The dev's ## Patch round (#20297) section is appended verbatim and marked as the seat's append.
    2. Scope. The net diff against main is the same five files as the first round (+1019 / −9). There is a changeset (minor, **BREAKING**, !, Clause-②: yes, ADR-0087 not-required), no content/docs/releases/** edit, and git merge-tree against de091b50 is clean (mergeable_state: clean).
    3. Implements B as routed (5859496956).
      • Both catalog reads go through catalogReadContext = { ...context, isSystem: true }, with the context threaded as a parameter (namesWithoutCatalogRow / idSpellingHints).
      • The :419 pin is reversed, not deleted: 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere message. Ablation B1 turns it red.
    4. The seat's assumption (1a) is falsified, and the falsification is accepted. An update by id of a foreign organization's row and of an id that exists nowhere both answer 403 PERMISSION_DENIED, the same message, from the security middleware before the refusal runs. So the pre-image read stays bare; its docblock carries the reason, it is pinned, and ablation P1 turns the pin red.
    5. Also accepted. The hint post-filter was removed, measured by C1. The single-posture organization-bound writer pin is new, because the old single tests carried no tenant.
    6. CI on cb1d5be9, latest run per name: 33 success, including Lint & Repo Gates and TypeScript Type Check. The 2 skips are roster-expected: Console Pin Gate and Packed-tarball smoke (opt-in).
      • The mid-round red on 418b4411 was this PR's check:query-options-erasure (236 → 237), relayed to the dev. It is fixed by typing the options, with no baseline edit.
      • The reds on 75804ad6, c8b533d2 and 418b4411 in the type-check and Test Core aggregators were cancelled lanes on superseded heads, so zero action.
    7. Local evidence (dev). 141 files / 2914 tests; typecheck exit 0; 94 derived, 94 run, 0 NOT-MEASURED on cb1d5be9.

    Out of scope, noted rather than filed. The dev's two observations have no public-door reach: a context with organizationId but no tenantId reads unscoped (403 first on the isolated posture), and the group posture's membership union. They stay in the PR's Acceptance notes.

    Landing to-dos, recorded now:

    1. The isolated contract review, at the contract-review tier, on cb1d5be9. It is owed on this surface (.changeset prose, content/docs/**) and by Clause-②: yes, and is now being dispatched.
    2. Only on PASS: check-governed-merges.mjs --pr 20292, then pr_ready + automerge_enable through the relay, confirmed by added_to_merge_queue.
    3. After MERGED on origin/main: close out 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 and [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, stripping pm:* and the assignee with a note.

    Generated by Claude Code

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. Every single-posture catalog row has a null organization_id, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself.",
      "tests": "All at head 0f4bf26e (one commit on cb1d5be9, prose only: 3 files, +14/-9, 0 changed lines containing expect()). (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 2914 passed (2914)', unchanged from cb1d5be9. position-catalog-refusal.test.ts --reporter=verbose: 18 passed, including the renamed case '✓ ... single posture: an organization-bound writer still reads organization-less catalog rows'. (2) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (3) pnpm check:adr-0087-registration: exit 0; reads '.changeset/16712-position-catalog-refusal.md [BREAKING+bang] not-required (no-migration-prescription)'. (4) Row 23b did not move (no hit), so the census is unaffected. check-system-context-census still ran inside the union: 'OK — 106 elevation read sites in 19 packages across 45 files, living in 89 symbol(s)'. (5) Premise sweep over the PR's shipped and explanatory texts. Files: changeset, system-context.mdx, position-catalog-refusal.ts, its test, and the PR's security-plugin.ts lines. grep -i -E pattern: 'every (catalog row|position|`single`-posture catalog row)[^.]{0,40}(organization-less|carries no organization|null)|every position is|the organization-less catalog'. Positive control: on cb1d5be9 the pattern hits 4 lines, the reviewer's quoted sentence among them. They are changeset :44, position-catalog-refusal.ts :56, test :298 (name) and test :299 (comment). On 0f4bf26e: 0 hits (grep exit 1). Second control: 'organization-less' still occurs 2/2/4/3 times in changeset/mdx/module/test, so the pattern is live on the non-universal uses. Row 23b: 0 hits on both commits. It says 'its organization's rows plus the organization-less ones', which is not the universal premise. A fifth hit, outside the repo, is the pr_body_append single-posture bullet ('Every catalog row has a null organization_id'), corrected below. The reviewer's evidence was re-read in code: per-organization-catalog.ts seedCtx() takes no organization on single; resolve-authz-context.ts:403 sets tenantId from session.activeOrganizationId, and :417 sets ctx.tenantId. (6) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 0f4bf26e, no paths: 5 paths vs merge base 7b1e4a487, 94 commands, the same list as cb1d5be9. All 94 run on 0f4bf26e, exit captured after a single redirect: 94 exit 0. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero), exit 0. The derivation printed STALE TREE: 8 commits behind origin/main 10ea9eb2, with scripts/pm/dispatch-gates.mjs and scripts/check-issue-citations.mjs changed there. The one family main's newer copy adds (7338efe0: the bare, diff-scoped check-issue-citations.mjs) was run bare on 0f4bf26e: '6 citations, 6 resolves', exit 0. (7) Lint narrowed: eslint --no-inline-config --format json over the 3 .ts files: 3 files, 0 errors, 0 warnings. Config is not type-aware (0 'project:' keys). (8) The full packages build ran first, because the recreated worktree had no dist: turbo 71/71, 47 cached. Ablations are NOT re-run: this commit changes no code line and no assertion. B1/C1/P1 at cb1d5be9 stand for the code, which is byte-identical in behaviour.",
      "mcp_calls": "0",
      "api_writes": "2 — this round: 1 POST /repos/objectstack-ai/objectstack/issues/20297/comments (this os-dev-report, via scripts/pm/post-stamped.mjs, fleet-write route). The earlier round: 1 POST to the same endpoint (comment 5860074760). Plus git pushes, which are not REST: 5 in all, the last cb1d5be9 -> 0f4bf26e, all fast-forward. No PR-body PATCH, no label or assignee write.",
      "gates": "0f4bf26e: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0 (derivation flagged the tree 8 commits behind origin/main; the one family main adds was run by hand, green)",
      "files_changed_this_round": [
        ".changeset/16712-position-catalog-refusal.md",
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts",
        "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)",
        "0f4bf26e single-posture premise reworded (contract review 5860245907, defect 2)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded.",
        "Contract review 5860245907, defect 2: the single-posture premise is reworded in 4 places, of which 1 is a test NAME. The case 'single posture: an organization-bound writer still reads the organization-less catalog' is now '… still reads organization-less catalog rows'. Its comment changed; its assertions did not. The conclusion is kept everywhere: one organization plus organization-less rows is the whole catalog.",
        "The previous report's summary (assumption 3) says 'Every single-posture catalog row has a null organization_id'. That carries the same universal premise; read it as 'the declared catalog is seeded with no organization'. The summary field is left as it was, per the coordinator's field list, and corrected here.",
        "api_writes is updated although the coordinator's field list does not name it: os-dev.md requires every write to be accounted, and this round adds one comment.",
        "The worktree was recreated for this round, because it had been removed after the first report. A full packages build was needed before the gates: 71/71 tasks, 47 from cache.",
        "Branch not re-synced with origin/main 10ea9eb2 (8 commits ahead of the merge base): not asked for, and main's range touches none of this PR's 5 paths (git diff --name-only 7b1e4a48 origin/main | grep: exit 1). PR #20292 reads mergeable_state clean at 0f4bf26e."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name"
      ],
      "pr_body_append": "Replace the single-posture bullet under **Mechanism readings** in the `## Patch round (#20297)` section with:\n\n- **`single` posture.** The fixture seeds its catalog the way a `single` deployment seeds its declared catalog, with no organization, so both rows read a null `organization_id`. That is not true of every row on a `single` deployment: a position created through the data door by a session with an active organization is stamped with it. But such a deployment has one organization, so the scoped read (`organization_id = tenant OR organization_id IS NULL`) still covers the whole catalog. The existing single-posture tests carry no tenant, so they never exercise the organization-less term. A new pin writes with `tenantId: 'org_a'`: `qa_auditor` is accepted and an unknown name is refused.\n",
      "head": "0f4bf26e"
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. Every single-posture catalog row has a null organization_id, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself.",
      "tests": "All at head ebf00fd8. That is one prose-only commit on 0f4bf26e: 1 file, +5/-5, all inside the module docblock; no code, test or other text touched. (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 2914 passed (2914)'. (2) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (3) Phrase sweep for 'one organization' / 'the whole catalog' over the changeset, system-context.mdx, position-catalog-refusal.ts, its test and security-plugin.ts. A plain per-line grep first gave a FAILED control: on 0f4bf26e it missed the docblock, because both phrases wrap across comment lines there ('the deployment's one / organization', 'the whole / catalog'). So the sweep was re-run wrap-aware: comment leaders stripped, each line joined with the next. Positive control, 0f4bf26e: it finds the overstated docblock at position-catalog-refusal.ts:58 ('the deployment's one organization — or with none') and :59 ('either way every writer reads the whole catalog'). On ebf00fd8: position-catalog-refusal.ts:57-58 now read 'when the deployment holds one organization every writer reads the whole catalog', which is conditional. Changeset :45-46 ('On a single-organization deployment … the one organization there is, so every writer sees the whole catalog') is conditional and untouched, as instructed. system-context.mdx and the test file have 0 hits. security-plugin.ts has 9 hits (:3880 … :6206), all pre-existing main lines outside this PR's hunk: the PR's added lines there have 0 hits. ADR-0131 §1.2 item 3 was read at docs/adr/0131-total-organization-ownership-no-null-organization-id.md :132-139 ('reports that state at error at boot … and does not refuse it'). (4) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at ebf00fd8, no paths: 5 paths vs merge base 7b1e4a487, 94 commands, the same list as 0f4bf26e. All 94 run on ebf00fd8, exit captured after a single redirect: 94 exit 0. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero), exit 0. STALE TREE was printed again: 10 commits behind origin/main dfd8e398. The stale derivation files changed only in 7338efe0, whose one added family (the bare, diff-scoped check-issue-citations.mjs) was run bare on ebf00fd8: exit 0, '7 citations judged, 7 resolves' (7 now, 6 before: the new docblock cites #17010). (5) eslint --no-inline-config --format json on the changed file: 1 file, 0 errors, 0 warnings. (6) The worktree was recreated, so a packages build ran first: turbo 71/71, 47 cached. Ablations and adr-0087 not re-run: no code, assertion or changeset byte changed; at 0f4bf26e check:adr-0087-registration read [BREAKING+bang] not-required (no-migration-prescription).",
      "mcp_calls": "0",
      "api_writes": "3 in all, every one POST /repos/objectstack-ai/objectstack/issues/20297/comments via scripts/pm/post-stamped.mjs (fleet-write route): comment 5860074760 (round 1), comment 5860416866 (the 0f4bf26e fix), and this os-dev-report (the ebf00fd8 fix). Plus git pushes, which are not REST: 6 in all, the last 0f4bf26e -> ebf00fd8, all fast-forward. No PR-body PATCH, no label or assignee write, 0 MCP calls.",
      "gates": "ebf00fd8: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0 (tree flagged 10 commits behind origin/main; the one family main adds, the diff-scoped check-issue-citations.mjs, run by hand: exit 0)",
      "files_changed_this_round": [
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)",
        "0f4bf26e single-posture premise reworded (contract review 5860245907, defect 2)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded.",
        "Contract review 5860245907, defect 2: the single-posture premise is reworded in 4 places, of which 1 is a test NAME. The case 'single posture: an organization-bound writer still reads the organization-less catalog' is now '… still reads organization-less catalog rows'. Its comment changed; its assertions did not. The conclusion is kept everywhere: one organization plus organization-less rows is the whole catalog.",
        "The previous report's summary (assumption 3) says 'Every single-posture catalog row has a null organization_id'. That carries the same universal premise; read it as 'the declared catalog is seeded with no organization'. The summary field is left as it was, per the coordinator's field list, and corrected here.",
        "api_writes is updated although the coordinator's field list does not name it: os-dev.md requires every write to be accounted, and this round adds one comment.",
        "The worktree was recreated for this round, because it had been removed after the first report. A full packages build was needed before the gates: 71/71 tasks, 47 from cache.",
        "Branch not re-synced with origin/main 10ea9eb2 (8 commits ahead of the merge base): not asked for, and main's range touches none of this PR's 5 paths (git diff --name-only 7b1e4a48 origin/main | grep: exit 1). PR #20292 reads mergeable_state clean at 0f4bf26e.",
        "Contract-review follow-up (docblock, ebf00fd8): the sentence 'Under a `single` posture … stamped with the session's active organization — the deployment's one organization — or with none; either way every writer reads the whole catalog' was rewritten as two clauses. Now: when the deployment holds one organization every writer reads the whole catalog; a `single` deployment holding several (reported at error at boot, #17010) gives each writer its active organization's positions plus the organization-less ones. No other text, code or test changed.",
        "The first phrase grep for this follow-up was a failed control, because it was line-based while the phrases wrap across docblock lines. It is replaced by a wrap-aware sweep whose control finds the old sentence on 0f4bf26e. Both readings are reported in tests.",
        "commits_this_round is not updated, per the coordinator's field list; the new commit is ebf00fd8.",
        "mergeable was 'unknown' (still computing) when read right after the push. main's range since the merge base touches none of this PR's 5 paths (grep exit 1), so no conflict is expected."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name"
      ],
      "pr_body_append": "Replace the single-posture bullet under **Mechanism readings** in the `## Patch round (#20297)` section with:\n\n- **`single` posture.** The fixture seeds its catalog the way a `single` deployment seeds its declared catalog, with no organization, so both rows read a null `organization_id`. That is not true of every row on a `single` deployment: a position created through the data door by a session with an active organization is stamped with it. But such a deployment has one organization, so the scoped read (`organization_id = tenant OR organization_id IS NULL`) still covers the whole catalog. The existing single-posture tests carry no tenant, so they never exercise the organization-less term. A new pin writes with `tenantId: 'org_a'`: `qa_auditor` is accepted and an unknown name is refused.\n",
      "head": "ebf00fd8"
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. The single fixture's catalog rows have a null organization_id, as a single deployment's declared catalog does, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself. Round 4 (contract re-review 5860712690), branch (a): measured on ebf00fd8 before any change, every non-string position is stored with 201 on both fixtures. So a number, bigint or boolean is now judged by String(value), and an object or array by its JSON (the form it is stored in), each refused reference_not_found like any unknown name. The by-id echo compares stored text, and the id-hint pin now asserts the full envelope.",
      "tests": "All at head 6e1ef6aa unless noted. (1) MEASUREMENT FIRST, on ebf00fd8 before any code change, from a scratch probe file (deleted, never committed). Non-system insert as admin, same on the single and the walled fixture: position 123 -> 201, stored '123.0'; true -> 201, '1.0'; {} -> 201, '{}'; ['x'] -> 201, '[\"x\"]'; 0 and false -> 201, '0.0'; [] -> 201, '[]'; [''] -> 201, '[\"\"]'; ['   '] -> 201; 10n -> 201, '10'; NaN -> 201, stored NULL; 1e119 -> 201. By-id update from 'qa_auditor' to 123 / true -> 200, stored '123.0' / '1.0'. An echo of 123 over a system-stored '123' -> 200. The engine answers only these: '' and '   ' and null -> 400 VALIDATION_FAILED fields [position, required]; a 101-char string -> 400 [position, max_length]. Code reading agrees: record-validator.ts:697 reads String(value) for the length checks and refuses no non-string. The docblock's old claim of an 'invalid_type' refusal was false. (2) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2, under os-verify-lock: VERDICT command-exit 0, 'Test Files 141 passed (141) / Tests 2917 passed (2917)' (+3). position-catalog-refusal.test.ts: 21 passed. New: insert of 123 / true / {} / ['qa_auditor'] refused, each asserting code VALIDATION_FAILED + status 400 + fields[0] {position, reference_not_found, value '123' / 'true' / '{}' / '[\"qa_auditor\"]'} + message === positionNotInCatalogMessage(text), with nothing stored. Also new: by-id update to 123 / true refused (code+status, field, code, value), the row untouched; and 123 echoed over a stored '123' not judged. The id-hint case now asserts [VALIDATION_FAILED, 400] and {field: position, code: reference_not_found} for both refusals. (3) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (4) Ablations at 6e1ef6aa, via scripts/ablation-replace.mjs WRAP plus a shell trap restoring the absolute path from HEAD. Each leg printed 'ok mutation landed: anchor 1 -> 0' and 'ok restored: blob == HEAD (7e1aff345cf8) and git diff HEAD is empty', and the trap re-proved it. The subject resolves through relative src imports, so no dist preflight applies. N1, judgedName reverted to strings only: 2 failed / 19 passed, the insert and the update pin, 'Error: expected the write to be refused, but it succeeded'. N2, echo compare reverted to raw ===: 1 failed / 20 passed, the echo pin, 'ValidationError: Position: no position is named '123''. N3, JSON form removed so objects fall back to String(): 1 failed / 20 passed, the insert pin, '{}: expected { field: position … } to match' (value '[object Object]'). Code reads are unchanged since cb1d5be9, so B1/C1/P1 were not re-run. (5) Gates: plugin-security rebuilt first (check-dts-emitted 1/1). node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 6e1ef6aa, no paths: 5 paths vs merge base 7b1e4a487, 94 commands, the same list. All 94 run on 6e1ef6aa, exit captured after a single redirect: 94 exit 0. Among them, pnpm check:query-options-erasure: 'test surface: 236 site(s) … at the ceiling'; no new find was added. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero), exit 0. STALE TREE printed: 12 commits behind origin/main. The only derivation change in that range is 7338efe0, whose added bare check-issue-citations.mjs run on 6e1ef6aa gave '7 citations judged, 7 resolves', exit 0. (6) pnpm check:adr-0087-registration: exit 0, '[BREAKING+bang] not-required (no-migration-prescription)'. The changeset is unchanged; its 'Such a write is now refused' and 'Which writes' are true as written now that non-strings are judged. (7) eslint --no-inline-config --format json on the 2 changed files: 2 files, 0 errors, 0 warnings. The control-byte scan of both files: 0 lines.",
      "mcp_calls": "0",
      "api_writes": "4 in all, every one POST /repos/objectstack-ai/objectstack/issues/20297/comments via scripts/pm/post-stamped.mjs (fleet-write route): comments 5860074760, 5860416866, 5860563688, and this os-dev-report (round 4). Plus git pushes, which are not REST: 7 in all, the last ebf00fd8 -> 6e1ef6aa, all fast-forward. No PR-body PATCH, no label or assignee write, 0 MCP calls.",
      "gates": "6e1ef6aa: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0 (tree 12 commits behind origin/main; the one family main adds, the diff-scoped check-issue-citations.mjs, run by hand: exit 0)",
      "files_changed_this_round": [
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts",
        "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)",
        "0f4bf26e single-posture premise reworded (contract review 5860245907, defect 2)",
        "ebf00fd8 single-posture docblock sentence conditional on one organization",
        "6e1ef6aa non-string position judged by its stored text + id-hint envelope asserts (contract re-review 5860712690)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded.",
        "Contract review 5860245907, defect 2: the single-posture premise is reworded in 4 places, of which 1 is a test NAME. The case 'single posture: an organization-bound writer still reads the organization-less catalog' is now '… still reads organization-less catalog rows'. Its comment changed; its assertions did not. The conclusion is kept everywhere: one organization plus organization-less rows is the whole catalog.",
        "api_writes is updated although the coordinator's field list does not name it: os-dev.md requires every write to be accounted, and this round adds one comment.",
        "The worktree was recreated for this round, because it had been removed after the first report. A full packages build was needed before the gates: 71/71 tasks, 47 from cache.",
        "Branch not re-synced with origin/main 10ea9eb2 (8 commits ahead of the merge base): not asked for, and main's range touches none of this PR's 5 paths (git diff --name-only 7b1e4a48 origin/main | grep: exit 1). PR #20292 reads mergeable_state clean at 0f4bf26e.",
        "Contract-review follow-up (docblock, ebf00fd8): the sentence 'Under a `single` posture … stamped with the session's active organization — the deployment's one organization — or with none; either way every writer reads the whole catalog' was rewritten as two clauses. Now: when the deployment holds one organization every writer reads the whole catalog; a `single` deployment holding several (reported at error at boot, #17010) gives each writer its active organization's positions plus the organization-less ones. No other text, code or test changed.",
        "The first phrase grep for this follow-up was a failed control, because it was line-based while the phrases wrap across docblock lines. It is replaced by a wrap-aware sweep whose control finds the old sentence on 0f4bf26e. Both readings are reported in tests.",
        "mergeable was 'unknown' (still computing) when read right after the push. main's range since the merge base touches none of this PR's 5 paths (grep exit 1), so no conflict is expected.",
        "Round 4, BRANCH (a) TAKEN; the reviewer's premise held. Measured on ebf00fd8 before any change: every non-string position (123, true, {}, ['x'], [], NaN, 10n) is stored with 201 on both fixtures. Only null, blank strings and over-long strings are refused by the engine (required / max_length). The docblock's 'invalid_type' claim was false.",
        "Objects and arrays: the engine stores them silently too, so they are judged the same way. The string form is their JSON text, the form they are stored in ('{}', '[\"x\"]'), not String(value). String(['qa_auditor']) is 'qa_auditor', which would accept an array naming a real position that then resolves nothing; a pin covers exactly that array. The over-length stand-down reads String(value), mirroring the engine's max_length check.",
        "Beyond the directive's four pins: the object and array insert legs, the echo pin (123 over a stored '123'), and ablations N2 (raw echo compare) and N3 (no JSON form), both red as expected.",
        "The changeset and system-context.mdx were left unchanged: the changeset's 'Such a write is now refused' / 'Which writes' are true as written after (a). The one PR-body sentence that changes ('Writes judged') has its replacement in pr_body_append, plus a round-4 section to append. The PR body was not PATCHed.",
        "Measured limit, not fixed (outside the boundary): on SQLite a non-string is stored as the driver's text ('123.0', '1.0'), not as String(value). A non-string whose String() form equals a catalog name (true, with a position named 'true') is therefore accepted, and stored in a form that resolves nothing. sys_position.name has no pattern. The root is the engine's text leniency for non-string input (record-validator reads String(value)), measured at the engine layer only, not at a public door. It is in out_of_scope_findings, not filed.",
        "The summary's assumption-(3) sentence, which carried the universal single-posture premise, is corrected in place this round, since every field may change now."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name",
        "carrier: 承接者:无 · measured at the engine layer only, not at a public door, so reach is unmeasured and it is not filed. A Field.text column accepts non-string input: record-validator.ts reads String(value) for its checks and refuses no number, boolean, object or array. The SQLite driver stores the driver's text: 123 -> '123.0', true -> '1.0', NaN -> NULL on a required column, [] -> '[]'. This refusal now judges sys_user_position.position by that text, but every other text column keeps the leniency, and the stored form is driver-dependent. Noted in the PR round-4 section · dedupe words: Field.text non-string coercion, text column number stored 123.0, NaN stored null required field"
      ],
      "pr_body_append": "**1. Replace** the \"Writes judged\" bullet under **What changes** with:\n\n- **Writes judged:** every non-system insert (one row or a batch; the batch is refused whole), a non-system update by id that CHANGES `position`, and a predicate update (`multi: true`) that sets it.\n  - A `position` that is not a string is judged too, by the text it is stored as: a number, bigint or boolean by `String(value)`, an object or array by its JSON. The engine's `text` validation refuses none of these and stores them with 201.\n  - Only `null`, a blank string (`required`) and a value longer than the column (`max_length`) are left to the engine.\n  - An update that edits another column, or echoes the stored `position` back unchanged, is not judged, so an existing row whose name has left the catalog stays editable. The comparison reads stored text, so `123` echoed over a stored `'123'` counts as unchanged.\n\n**2. Append** this section after `## Patch round (#20297)`:\n\n## Patch round 4 (contract re-review 5860712690)\n\n**Measured first.** On head `ebf00fd8`, before any code change, I ran non-system inserts and by-id updates as an admin, on both the single and the walled fixture, with the same result on each:\n\n| `position` written | answer | stored as |\n| --- | --- | --- |\n| `123` | 201 | `'123.0'` |\n| `true` | 201 | `'1.0'` |\n| `{}` | 201 | `'{}'` |\n| `['x']` | 201 | `'[\"x\"]'` |\n| `[]` | 201 | `'[]'` |\n| `NaN` | 201 | `NULL` |\n| `10n` | 201 | `'10'` |\n| by-id update to `123` or `true` | 200 | `'123.0'` / `'1.0'` |\n| `''`, `'   '` or `null` | 400 `VALIDATION_FAILED`, `required` | nothing |\n| a 101-character string | 400 `VALIDATION_FAILED`, `max_length` | nothing |\n\nSo the reviewer's premise held: the engine refuses only `null`, blank strings and over-long values, and stores everything else. Branch (a) applies.\n\n**What changed** (head `6e1ef6aa`)\n\n- `judgedName(value)` in `position-catalog-refusal.ts` now judges every value except those the engine answers itself.\n  - A number, bigint or boolean is judged by `String(value)`.\n  - An object or array is judged by its JSON, which is the form it is stored in. It is never judged by `String(['x'])`, which reads `'x'` and would accept an array naming a real position that then resolves nothing.\n  - The engine answers `null`, a blank string, and any value whose `String()` form is longer than the column. The engine's `max_length` check reads `String(value)`.\n- The refusal is the usual one: `400 VALIDATION_FAILED`, `reference_not_found` at `position`, with the stored text as `value` and in the message.\n- The by-id unchanged-value check compares stored text.\n- The docblock's stand-down sentence now names only the refusals the engine really makes.\n- The changeset and `system-context.mdx` are unchanged; their \"Which writes\" and \"Such a write is now refused\" text is true as written.\n\n**Tests** (21 cases, up from 18). Every refusal pin asserts `code` and `status`.\n\n- **Insert:** `123`, `true`, `{}` and `['qa_auditor']` are each refused, with `value` equal to `'123'`, `'true'`, `'{}'` and `'[\"qa_auditor\"]'`, and nothing is stored.\n- **By-id update:** `123` and `true` are refused, and the stored row is untouched.\n- **Echo:** `123` over a stored `'123'` is not judged.\n- **Id hint:** now also asserts `VALIDATION_FAILED` / 400, plus `field` and `code` on `fields[0]`, for both refusals.\n\n**Ablations** (at `6e1ef6aa`, through `scripts/ablation-replace.mjs` in WRAP mode plus a shell trap; every restore was proven by blob == `HEAD` and an empty `git diff HEAD`):\n\n| ablation | mutation | result |\n| --- | --- | --- |\n| N1 | only strings are judged | 2 red: the insert and update pins (\"expected the write to be refused, but it succeeded\") |\n| N2 | the echo compares raw values | 1 red: the echo pin (`123` is refused as `'123'`) |\n| N3 | no JSON form, so objects fall back to `String()` | 1 red: the insert pin (`{}` is judged as `'[object Object]'`) |\n\n**Verification at `6e1ef6aa`**\n\n- plugin-security: 141 files / 2917 tests passed; typecheck exit 0.\n- `dispatch-gates --commands`: 94 derived, 94 run, all exit 0. The `--ran` reconciliation reports a derived zero.\n- `check:query-options-erasure` holds at 236, since no new `find` was added.\n- `check:adr-0087-registration` reads `[BREAKING+bang] not-required (no-migration-prescription)`.\n- The diff-scoped issue-citation check: 7 citations, all resolve.\n\n**Acceptance note.** On SQLite a non-string is stored as the driver's text (`'123.0'`, `'1.0'`), not as `String(value)`. So a non-string whose `String()` form happens to equal a catalog name, for example `true` where a position is named `true`, is accepted and stored in a form that resolves nothing. `sys_position.name` has no pattern that rules such names out. The root cause is the engine's `text` leniency for non-string input, which is outside this card; it is measured at the engine layer only.\n",
      "head": "6e1ef6aa"
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. The single fixture's catalog rows have a null organization_id, as a single deployment's declared catalog does, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself. Round 4 (contract re-review 5860712690), branch (a): measured on ebf00fd8 before any change, every non-string position is stored with 201 on both fixtures. So a number, bigint or boolean is now judged by String(value), and an object or array by its JSON (the form it is stored in), each refused reference_not_found like any unknown name. The by-id echo compares stored text, and the id-hint pin now asserts the full envelope.",
      "tests": "All at head 0e5f4c79. That is one prose-only commit on 6e1ef6aa: 2 files, +24/-20. Every changed line in the .ts files is a comment, except the two renamed test-name strings; 0 changed expect/await lines. (1) Sweep for stored-text claims over position-catalog-refusal.ts and its test: grep -i 'stored as|stored text|text it is stored|stores it in|value's text|as text'. Positive control, 6e1ef6aa: 10 hits. Six are in the module: :69 (\"value's text differs\"), :87 and :88 ('stores it as text' / 'judged by the text they are stored as'), :199 and :201 (the stringForm doc, 'The text a position value is stored as' / 'the form a text column stores it in') and :299 (the echo comment, 'Compared as stored text'). Four are in the test: :377, :380, :382 and :384. On 0e5f4c79: 2 hits, both new sentences that deny the equation: module :90 ('The stored text is the driver's, not that string form (SQLite stores 123 as '123.0')') and :206 in the stringForm doc. Reworded: the 'Which writes' bullet ('value's string form differs from the string form of the value the row already stores'); the stand-down bullet (judged by their string form, with the driver-dependent stored text and the accepted-and-resolves-nothing corner named as the engine's text leniency, outside this refusal); the stringForm doc; and the echo comment ('Compared by string form'), which matches stringForm(prev) === nextName. Test NAMES changed (assertions untouched): the describe 'a non-string position is judged by the text it would be stored as' is now '… judged by its string form'. The it '… reference_not_found at their stored text' is now '… at their string form'. The echo case name ('123 echoed over a stored '123'') is kept: that row really stores the string '123', written by a system insert. (2) The packages build ran first, because the recreated worktree had no dist: turbo 71/71, 47 cached. pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2, under os-verify-lock: VERDICT command-exit 0, 'Test Files 141 passed (141) / Tests 2917 passed (2917)'. The verbose run of position-catalog-refusal.test.ts: 21 passed, including the renamed '✓ … a non-string position is judged by its string form > insert: … at their string form'. (3) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (4) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 0e5f4c79, no paths: 5 paths vs merge base 7b1e4a487, 94 commands, the same list. All 94 run on 0e5f4c79, exit captured after a single redirect: 94 exit 0. check:query-options-erasure still reads 'test surface: 236 site(s)'. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero), exit 0. STALE TREE was printed: 12 commits behind origin/main. The one family main adds (7338efe0, the bare diff-scoped check-issue-citations.mjs) was run on 0e5f4c79: '7 citations judged, 7 resolves', exit 0. (5) eslint --no-inline-config --format json on the 2 files: 2 files, 0 errors, 0 warnings. Ablations are not re-run: no code or assertion changed since 6e1ef6aa, where N1/N2/N3 went red as expected and every restore was proven.",
      "mcp_calls": "0",
      "api_writes": "5 in all, every one POST /repos/objectstack-ai/objectstack/issues/20297/comments via scripts/pm/post-stamped.mjs (fleet-write route): comments 5860074760, 5860416866, 5860563688, 5860889632, and this os-dev-report (round 5). Plus git pushes, which are not REST: 8 in all, the last 6e1ef6aa -> 0e5f4c79, all fast-forward. No PR-body PATCH, no label or assignee write, 0 MCP calls.",
      "gates": "0e5f4c79: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0 (tree 12 commits behind origin/main; the one family main adds, the diff-scoped check-issue-citations.mjs, run by hand: exit 0)",
      "files_changed_this_round": [
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts",
        "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)",
        "0f4bf26e single-posture premise reworded (contract review 5860245907, defect 2)",
        "ebf00fd8 single-posture docblock sentence conditional on one organization",
        "6e1ef6aa non-string position judged by its stored text + id-hint envelope asserts (contract re-review 5860712690)",
        "0e5f4c79 a non-string position is judged by its string form, not its stored text (docblock and test names only)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded.",
        "Contract review 5860245907, defect 2: the single-posture premise is reworded in 4 places, of which 1 is a test NAME. The case 'single posture: an organization-bound writer still reads the organization-less catalog' is now '… still reads organization-less catalog rows'. Its comment changed; its assertions did not. The conclusion is kept everywhere: one organization plus organization-less rows is the whole catalog.",
        "api_writes is updated although the coordinator's field list does not name it: os-dev.md requires every write to be accounted, and this round adds one comment.",
        "The worktree was recreated for this round, because it had been removed after the first report. A full packages build was needed before the gates: 71/71 tasks, 47 from cache.",
        "Branch not re-synced with origin/main 10ea9eb2 (8 commits ahead of the merge base): not asked for, and main's range touches none of this PR's 5 paths (git diff --name-only 7b1e4a48 origin/main | grep: exit 1). PR #20292 reads mergeable_state clean at 0f4bf26e.",
        "Contract-review follow-up (docblock, ebf00fd8): the sentence 'Under a `single` posture … stamped with the session's active organization — the deployment's one organization — or with none; either way every writer reads the whole catalog' was rewritten as two clauses. Now: when the deployment holds one organization every writer reads the whole catalog; a `single` deployment holding several (reported at error at boot, #17010) gives each writer its active organization's positions plus the organization-less ones. No other text, code or test changed.",
        "The first phrase grep for this follow-up was a failed control, because it was line-based while the phrases wrap across docblock lines. It is replaced by a wrap-aware sweep whose control finds the old sentence on 0f4bf26e. Both readings are reported in tests.",
        "mergeable was 'unknown' (still computing) when read right after the push. main's range since the merge base touches none of this PR's 5 paths (grep exit 1), so no conflict is expected.",
        "Round 4, BRANCH (a) TAKEN; the reviewer's premise held. Measured on ebf00fd8 before any change: every non-string position (123, true, {}, ['x'], [], NaN, 10n) is stored with 201 on both fixtures. Only null, blank strings and over-long strings are refused by the engine (required / max_length). The docblock's 'invalid_type' claim was false.",
        "Objects and arrays: the engine stores them silently too, so they are judged the same way. The string form is their JSON text, the form they are stored in ('{}', '[\"x\"]'), not String(value). String(['qa_auditor']) is 'qa_auditor', which would accept an array naming a real position that then resolves nothing; a pin covers exactly that array. The over-length stand-down reads String(value), mirroring the engine's max_length check.",
        "Beyond the directive's four pins: the object and array insert legs, the echo pin (123 over a stored '123'), and ablations N2 (raw echo compare) and N3 (no JSON form), both red as expected.",
        "The changeset and system-context.mdx were left unchanged: the changeset's 'Such a write is now refused' / 'Which writes' are true as written after (a). The one PR-body sentence that changes ('Writes judged') has its replacement in pr_body_append, plus a round-4 section to append. The PR body was not PATCHed.",
        "Measured limit, not fixed (outside the boundary): on SQLite a non-string is stored as the driver's text ('123.0', '1.0'), not as String(value). A non-string whose String() form equals a catalog name (true, with a position named 'true') is therefore accepted, and stored in a form that resolves nothing. sys_position.name has no pattern. The root is the engine's text leniency for non-string input (record-validator reads String(value)), measured at the engine layer only, not at a public door. It is in out_of_scope_findings, not filed.",
        "The summary's assumption-(3) sentence, which carried the universal single-posture premise, is corrected in place this round, since every field may change now.",
        "Round 5 (docblock only): 'judged by the text it is stored as' is reworded to 'judged by its string form' everywhere it appeared: the module's stand-down bullet, the stringForm doc, the 'Which writes' bullet, the echo comment, and the test's section comment, describe name and one it name. Each place now says the stored text is the driver's (SQLite stores 123 as '123.0'), so a non-string whose string form equals a catalog name is accepted and resolves nothing, the engine's text leniency and outside this card. No code or assertion change; 2 test NAMES changed, listed in tests.",
        "pr_body_append is omitted from this report, as instructed: the seat writes the body text itself. The round-4 section's 'the form it is stored in' / 'stored text as value' phrasing has the same overstatement, for the seat to reword the same way."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name",
        "carrier: 承接者:无 · measured at the engine layer only, not at a public door, so reach is unmeasured and it is not filed. A Field.text column accepts non-string input: record-validator.ts reads String(value) for its checks and refuses no number, boolean, object or array. The SQLite driver stores the driver's text: 123 -> '123.0', true -> '1.0', NaN -> NULL on a required column, [] -> '[]'. This refusal now judges sys_user_position.position by that text, but every other text column keeps the leniency, and the stored form is driver-dependent. Noted in the PR round-4 section · dedupe words: Field.text non-string coercion, text column number stored 123.0, NaN stored null required field"
      ],
      "head": "0e5f4c79"
    }

    Generated by Claude Code

  9. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20297,
      "status": "done",
      "branch": "claude/issue-16712-position-catalog-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20292",
      "session": "session_01TEah6PeJGjxJfbHaySJjLQ — subagent run; this is the parent (PM) session id as the harness stamps it",
      "premise_still_valid": true,
      "summary": "Patch round on PR #20292 executing B: the catalog reads now run under { ...context, isSystem: true }, the engine lookup probe's spelling. namesWithoutCatalogRow(deps, names, context) and idSpellingHints(deps, values, context) take the writer's context as a required parameter, and assertPositionNamesCatalogRow passes opCtx.context; security-plugin.ts is not touched this round. A name only another organization carries is now refused 400 VALIDATION_FAILED / reference_not_found, with a message byte-identical to the exists-nowhere one. The walled pin at :419 is REVERSED, not deleted, and ablation B1 turns it red. PM mechanism assumptions: (1a) FALSIFIED. On the walled fixture at 75804ad6 (refusal module blob identical to cbdd0e70's), an update by id of an org_b row and of an id that exists nowhere both answer 403 PERMISSION_DENIED with the same message: the security middleware refuses both before the refusal runs. So the pre-image read (a) stays bare, pinned, and ablation P1 turns that pin red. (1c) The hint read takes the scoped spelling and its post-filter is REMOVED. With the scoped read the suite is green without it, and C1 shows the hint pin fails only when the read itself is unscoped. (2) Held: the context is threaded as a parameter, with no module-level value and no security-plugin.ts change. (3) Partly falsified. The single fixture's catalog rows have a null organization_id, as a single deployment's declared catalog does, and the existing single tests stay green unchanged, but they carry no tenantId, so they never exercise the organization-less term. A NEW pin writes as tenantId org_a: qa_auditor is accepted and an unknown name refused. (4) Not expressible through the full chain: on the isolated posture a no-organization writer gets 403 ('no active organization') from the security middleware first, for insert and for predicate update. It is pinned at namesWithoutCatalogRow instead: reads every organization with no tenant, scoped with tenantId org_a. The merge of origin/main 7b1e4a48 (2f036209) had one conflict, the census file-count row. gen:system-context-census re-derived symbols 88 to 89 (75804ad6), and PR mergeable_state now reads clean. The changeset and row 23b state B; the module docblock gains a 'Whose catalog' section citing #20297 and #19808/#19819/#19860, and 'Where it runs' no longer claims authorization-first protects an unscoped read. Mid-round the PM relayed a CI red on 418b4411: check:query-options-erasure 236 to 237, from the pre-image pin's find(... as any). It is fixed in cb1d5be9 by typing the options (back to 236); the baseline is not raised. The seat's PR-body text is in pr_body_append; the seat adds Fixes #20297 itself. Round 4 (contract re-review 5860712690), branch (a): measured on ebf00fd8 before any change, every non-string position is stored with 201 on both fixtures. So a number, bigint or boolean is now judged by String(value), and an object or array by its JSON (the form it is stored in), each refused reference_not_found like any unknown name. The by-id echo compares stored text, and the id-hint pin now asserts the full envelope. Round 6 (contract review 3, 5861153477), branch (a) plus one more fix. Measured on 0e5f4c79: operator objects already answered the engine's invalid_type, but only because the catalog read of their JSON text threw FILTER_TOKEN_UNKNOWN (a fully-wrapped {…} where-comparand is a filter placeholder) and the refusal failed open. The same fail-open let { a: 1 }, { $foo: 1 } and '{nope_tok}' be stored with 201. judgedName now stands down on an operator object, mirroring the engine's #5922 predicate from the same spec inputs, and a placeholder-shaped name is compared literally (catalogCarries), so it is judged and never resolved or failed open.",
      "tests": "All at head c282e608 unless noted. (1) MEASUREMENT FIRST, on 0e5f4c79 before any code change, single fixture, from a scratch probe file (deleted, never committed). The probe ran twice: with the refusal, and with it ablated (ablation-replace WRAP, 'ok mutation landed' / 'ok restored: blob == HEAD'). Engine alone: { $in: ['x'] }, { $in: [], a: 1 }, { $regex: 'x' }, { $or: [] } -> 400 VALIDATION_FAILED [position, invalid_type], message '$in is a filter operator, not a value …'. By-id update to { $in: ['x'] } -> 400 invalid_type, row untouched. { a: 1 }, { $foo: 1 }, {}, [{ $in: 1 }], '{nope_tok}', '{current_user_id}' and '{today}' -> 201, stored. With the refusal: the operator objects -> 400 invalid_type, the same. { a: 1 } -> 201, stored '{\"a\":1}'. { $foo: 1 } -> 201. '{nope_tok}' -> 201. '{current_user_id}' and '{today}' -> 400 reference_not_found. {} and [{ $in: 1 }] -> 400 reference_not_found. Direct calls: namesWithoutCatalogRow(['{\"a\":1}']) -> null, with warn '[security] the sys_position catalog could not be read …' and error 'Unresolvable filter placeholder \"{\"a\":1}\"'. find(sys_position, where name '{\"a\":1}') -> 400 FILTER_TOKEN_UNKNOWN. find with name '{}' -> 0 rows (not a placeholder). So the reviewer's predicted outcome (reference_not_found ahead of the engine) did not occur, because the refusal failed open; its mechanism (the refusal runs before validation) is real, and O1 below shows it once the lookup is literal. (2) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2, under os-verify-lock: VERDICT command-exit 0, 'Test Files 141 passed (141) / Tests 2920 passed (2920)' (+3). position-catalog-refusal.test.ts: 24 passed. New: (a) insert of { $in: ['x'] } and of { $in: [], a: 1 } -> [VALIDATION_FAILED, 400] and fields[0] {field: position, code: invalid_type}, nothing stored. (b) { a: 1 } and { $foo: 1 } -> [VALIDATION_FAILED, 400], fields[0] {position, reference_not_found, value '{\"a\":1}' / '{\"$foo\":1}'}, message === positionNotInCatalogMessage(text), nothing stored, 0 'could not be read' warns. (c) '{nope_tok}' and '{current_user_id}' -> [VALIDATION_FAILED, 400], reference_not_found with value equal to the literal; a sys_position named '{lit_pos}' is found and the assignment naming it is accepted; 0 'could not be read' warns. (3) pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0; 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (4) Ablations at c282e608, via scripts/ablation-replace.mjs WRAP plus a shell trap restoring the absolute path from HEAD. Every leg printed 'ok mutation landed', 'ok restored: blob == HEAD (92f5addcf1cc) and git diff HEAD is empty' and 'SHELL-TRAP restore proven'. O1, operator stand-down removed: 1 failed / 23 passed, the operator pin, '{\"$in\":[\"x\"]}: expected { field: position … } to match object' (it got reference_not_found). O2, literal lookup removed: 2 failed / 22 passed, the plain-object pin and the placeholder pin, both 'Error: expected the write to be refused, but it succeeded'. B1 / C1 / P1 / N1 / N2 / N3 re-run on c282e608: 2 / 1 / 3 / 3 / 1 / 2 failed, each restored and proven. N1 now also reddens the plain-object pin, and N3 the plain-object pin as well as the insert pin. (5) Gates: plugin-security rebuilt first. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at c282e608, no paths: 5 paths vs merge base 7b1e4a487, 94 commands, the same list. All 94 run on c282e608, exit captured after a single redirect: 94 exit 0. Among them: check:query-options-erasure 'test surface: 236 site(s)'; check:undeclared-dep-imports green (the new @objectstack/spec/data value import is a declared dependency); check:cross-package-test-inputs and check:test-source-alias OK. --ran with 'COMMAND :: exit N' records: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero), exit 0. STALE TREE printed: 13 commits behind origin/main. The one family main adds (7338efe0, the bare diff-scoped check-issue-citations.mjs) run on c282e608: '11 citations judged, 11 resolves', exit 0. (6) eslint --no-inline-config --format json on the 2 files: 2 files, 0 errors, 0 warnings. The control-byte scan: 0 lines. (7) The changeset is unchanged; its 'Which writes' is still true as written: the judged write shapes did not change, only which values the engine answers.",
      "mcp_calls": "0",
      "api_writes": "6 in all, every one POST /repos/objectstack-ai/objectstack/issues/20297/comments via scripts/pm/post-stamped.mjs (fleet-write route): comments 5860074760, 5860416866, 5860563688, 5860889632, 5861031897, and this os-dev-report (round 6). Plus git pushes, which are not REST: 9 in all, the last 0e5f4c79 -> c282e608, all fast-forward. No PR-body PATCH, no label or assignee write, 0 MCP calls.",
      "gates": "c282e608: 94 derived / 94 run / 0 NOT-MEASURED / 0 UNRUN; dispatch-gates --ran exit 0 (tree 13 commits behind origin/main; the one family main adds, the diff-scoped check-issue-citations.mjs, run by hand: exit 0)",
      "files_changed_this_round": [
        "packages/plugins/plugin-security/src/position-catalog-refusal.ts",
        "packages/plugins/plugin-security/src/position-catalog-refusal.test.ts"
      ],
      "commits_this_round": [
        "2f036209 merge origin/main 7b1e4a48",
        "75804ad6 census re-derive",
        "c8b533d2 scoped catalog read + pins",
        "418b4411 changeset + row 23b",
        "cb1d5be9 typed read options (query-options-erasure)",
        "0f4bf26e single-posture premise reworded (contract review 5860245907, defect 2)",
        "ebf00fd8 single-posture docblock sentence conditional on one organization",
        "6e1ef6aa non-string position judged by its stored text + id-hint envelope asserts (contract re-review 5860712690)",
        "0e5f4c79 a non-string position is judged by its string form, not its stored text (docblock and test names only)",
        "c282e608 operator object left to the engine (#5922 predicate mirrored) + placeholder-shaped names looked up literally (contract review 3, 5861153477)"
      ],
      "deviations": [
        "Mechanism measurement point: taken at the merge commit 75804ad6, not at cbdd0e70 itself. The refusal module and its test are blob-identical there (bfe123d631e8 / c98532a55830); security-plugin.ts carries main's merged changes. This saved a second dependency-closure build.",
        "The id-hint post-filter was removed, not kept: no reachable path needs it once the read is scoped. The one context it would still filter (organizationId without tenantId) cannot reach the refusal on the isolated posture (403 first). Measured by C1 and the probe.",
        "Four new pins beyond the reversed one, of which two are negative pins: the function-level scoped read (ablation B1) and the foreign-row-id pin (P1). The other two are acceptance controls: own-org name, and single-posture org-bound writer.",
        "query-options-erasure: the CI red the PM relayed came from this round's pre-image pin. It is fixed in cb1d5be9 by typing the options, with no baseline edit. The derived list DID include pnpm check:query-options-erasure (#81 of 94); the local union had not reached it when CI reported. Nothing was missed by the derivation.",
        "Worktree branch creation: the branch already existed locally at cbdd0e70, equal to the remote, so the worktree checks it out with upstream set, instead of using -b. No empty-branch push probe was needed: the first real push (the merge) was the write probe and succeeded.",
        "Contract review 5860245907, defect 2: the single-posture premise is reworded in 4 places, of which 1 is a test NAME. The case 'single posture: an organization-bound writer still reads the organization-less catalog' is now '… still reads organization-less catalog rows'. Its comment changed; its assertions did not. The conclusion is kept everywhere: one organization plus organization-less rows is the whole catalog.",
        "api_writes is updated although the coordinator's field list does not name it: os-dev.md requires every write to be accounted, and this round adds one comment.",
        "The worktree was recreated for this round, because it had been removed after the first report. A full packages build was needed before the gates: 71/71 tasks, 47 from cache.",
        "Branch not re-synced with origin/main 10ea9eb2 (8 commits ahead of the merge base): not asked for, and main's range touches none of this PR's 5 paths (git diff --name-only 7b1e4a48 origin/main | grep: exit 1). PR #20292 reads mergeable_state clean at 0f4bf26e.",
        "Contract-review follow-up (docblock, ebf00fd8): the sentence 'Under a `single` posture … stamped with the session's active organization — the deployment's one organization — or with none; either way every writer reads the whole catalog' was rewritten as two clauses. Now: when the deployment holds one organization every writer reads the whole catalog; a `single` deployment holding several (reported at error at boot, #17010) gives each writer its active organization's positions plus the organization-less ones. No other text, code or test changed.",
        "The first phrase grep for this follow-up was a failed control, because it was line-based while the phrases wrap across docblock lines. It is replaced by a wrap-aware sweep whose control finds the old sentence on 0f4bf26e. Both readings are reported in tests.",
        "mergeable was 'unknown' (still computing) when read right after the push. main's range since the merge base touches none of this PR's 5 paths (grep exit 1), so no conflict is expected.",
        "Round 4, BRANCH (a) TAKEN; the reviewer's premise held. Measured on ebf00fd8 before any change: every non-string position (123, true, {}, ['x'], [], NaN, 10n) is stored with 201 on both fixtures. Only null, blank strings and over-long strings are refused by the engine (required / max_length). The docblock's 'invalid_type' claim was false.",
        "Objects and arrays: the engine stores them silently too, so they are judged the same way. The string form is their JSON text, the form they are stored in ('{}', '[\"x\"]'), not String(value). String(['qa_auditor']) is 'qa_auditor', which would accept an array naming a real position that then resolves nothing; a pin covers exactly that array. The over-length stand-down reads String(value), mirroring the engine's max_length check.",
        "Beyond the directive's four pins: the object and array insert legs, the echo pin (123 over a stored '123'), and ablations N2 (raw echo compare) and N3 (no JSON form), both red as expected.",
        "The changeset and system-context.mdx were left unchanged: the changeset's 'Such a write is now refused' / 'Which writes' are true as written after (a). The one PR-body sentence that changes ('Writes judged') has its replacement in pr_body_append, plus a round-4 section to append. The PR body was not PATCHed.",
        "Measured limit, not fixed (outside the boundary): on SQLite a non-string is stored as the driver's text ('123.0', '1.0'), not as String(value). A non-string whose String() form equals a catalog name (true, with a position named 'true') is therefore accepted, and stored in a form that resolves nothing. sys_position.name has no pattern. The root is the engine's text leniency for non-string input (record-validator reads String(value)), measured at the engine layer only, not at a public door. It is in out_of_scope_findings, not filed.",
        "The summary's assumption-(3) sentence, which carried the universal single-posture premise, is corrected in place this round, since every field may change now.",
        "Round 5 (docblock only): 'judged by the text it is stored as' is reworded to 'judged by its string form' everywhere it appeared: the module's stand-down bullet, the stringForm doc, the 'Which writes' bullet, the echo comment, and the test's section comment, describe name and one it name. Each place now says the stored text is the driver's (SQLite stores 123 as '123.0'), so a non-string whose string form equals a catalog name is accepted and resolves nothing, the engine's text leniency and outside this card. No code or assertion change; 2 test NAMES changed, listed in tests.",
        "pr_body_append is omitted from this report, as instructed: the seat writes the body text itself. The round-4 section's 'the form it is stored in' / 'stored text as value' phrasing has the same overstatement, for the seat to reword the same way.",
        "Round 6, BRANCH (a) TAKEN, with the reviewer's premise corrected by measurement. On 0e5f4c79 the operator object did NOT get reference_not_found: it got the engine's invalid_type, but only because the refusal failed open. The catalog read put '{\"$in\":[\"x\"]}' in where, the query layer treats a fully-wrapped {…} comparand as a filter placeholder, it threw FILTER_TOKEN_UNKNOWN, and the refusal logged 'catalog could not be read' and passed the write on. The stand-down makes invalid_type the deliberate answer, and O1 shows it is needed once the lookup is literal.",
        "BEYOND THE DIRECTIVE, found by the round-6 measurement, same file: the placeholder fail-open was a live bypass. { a: 1 }, { $foo: 1 } and any brace-wrapped string like '{nope_tok}' were stored with 201 although the refusal claims to judge them. Known tokens ('{current_user_id}', '{today}') were resolved, so the lookup judged the user id or the date rather than the literal. Fixed inside position-catalog-refusal.ts: catalogCarries compares a classifyFilterToken-shaped name literally, reading the catalog names that share its first character via $startsWith under the same scoped context and comparing in code. Pinned, and ablation O2 is red. That read is unbounded but restricted to names beginning with '{' or '$', which the catalog should not hold in practice. The id hint skips such values.",
        "The predicate is mirrored, not imported: filterOperatorKeysIn is module-private in record-validator.ts. The mirror uses the same spec exports (isPlainRecord, ALL_OPERATORS, Object.keys(RETIRED_FILTER_OPERATORS)), excludes Date, and cites the source. The engine's valueMayBeAnObject gate is always true for position (a text, non-multi column), so it is not mirrored. { $foo: 1 } is judged, as the engine stores it (measured).",
        "The PR-body replacements (3 sentences plus a round-6 section) were handed to the seat in the report's pr_body_append, not PATCHed."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · observation, not filed. A context carrying only organizationId (no tenantId) is not tenant-scoped by ObjectQL buildDriverOptions, which keys on tenantId. Such a context reads every organization in this refusal (measured on the walled fixture: sys_position by name finds qa_b_only / qa_a_own / qa_auditor 1/1/1), and by the same driver-options reading in the engine's own lookup probe (code reading). It cannot reach this refusal at the write door on the isolated posture: the security middleware answers 403 'no active organization' first. No public-door reach, so not class (a). Noted in PR Acceptance notes · dedupe words: organizationId without tenantId driver scope, buildDriverOptions tenantId only, sudo context organizationId",
        "carrier: 承接者:无 · code reading, not measured. Under the group posture the scoped read follows the engine's membership union (tenantIds), so a name from another organization the writer belongs to is accepted and may resolve nothing in the active organization. Same semantics as the engine lookup probe. Noted in PR Acceptance notes, not filed · dedupe words: group posture position catalog membership union, tenantIds position name",
        "carrier: 承接者:无 · measured at the engine layer only, not at a public door, so reach is unmeasured and it is not filed. A Field.text column accepts non-string input: record-validator.ts reads String(value) for its checks and refuses no number, boolean, object or array. The SQLite driver stores the driver's text: 123 -> '123.0', true -> '1.0', NaN -> NULL on a required column, [] -> '[]'. This refusal now judges sys_user_position.position by that text, but every other text column keeps the leniency, and the stored form is driver-dependent. Noted in the PR round-4 section · dedupe words: Field.text non-string coercion, text column number stored 123.0, NaN stored null required field"
      ],
      "head": "c282e608",
      "pr_body_append": "**Replace** these three lines in the current body:\n\n1. The bullet that begins \"A `position` that is not a string is judged too\" (under **Writes judged**) becomes:\n   - A `position` that is not a string is judged too, by its string form: a number, bigint or boolean by `String(value)`, an object or array by its JSON text. The engine's `text` validation stores every one of these with 201, except an operator object (next bullet).\n2. The bullet that begins \"Only `null`, a blank string\" becomes:\n   - Left to the engine, and only these: `null` and a blank string (`required`), a value whose `String()` form is longer than the column (`max_length`), and an operator object. An operator object is a plain object with a declared filter operator as an own key, such as `{ $in: [...] }`; the engine refuses it as `invalid_type` (#5922).\n3. In the round-4 section, the bullet that begins \"The engine answers `null`, a blank string\" becomes:\n   - The engine answers `null`, a blank string, and any value whose `String()` form is longer than the column (its `max_length` check reads `String(value)`). Patch round 6 below adds the one refusal this missed: an operator object, `invalid_type` (#5922).\n\n**Append** this section:\n\n## Patch round 6 (contract review 3, 5861153477)\n\n**Measured first** on head `0e5f4c79`, on the single fixture, with the refusal in place and then with it ablated (`ablation-replace` WRAP; the restore was proven by blob == `HEAD`):\n\n| `position` | with the refusal | engine alone |\n| --- | --- | --- |\n| `{ $in: ['x'] }`, `{ $in: [], a: 1 }`, `{ $regex: 'x' }`, `{ $or: [] }` | 400 `invalid_type` | 400 `invalid_type` |\n| by-id update to `{ $in: ['x'] }` | 400 `invalid_type`, row untouched | 400 `invalid_type` |\n| `{ a: 1 }`, `{ $foo: 1 }` | **201, stored** | 201 |\n| `'{nope_tok}'` | **201, stored** | 201 |\n| `'{current_user_id}'`, `'{today}'` | 400 `reference_not_found` | 201 |\n| `{}`, `[{ $in: 1 }]` | 400 `reference_not_found` | 201 |\n\nThe reviewer's predicted outcome (`reference_not_found` pre-empting the engine) did not occur, but its cause is real. `invalid_type` came through only because the refusal failed open.\n\n- **The mechanism.** The catalog read put the value's text in `where`. A fully-wrapped `{…}` comparand is a filter placeholder (`classifyFilterToken` in `@objectstack/spec`, applied by `@objectstack/core`'s `resolveFilterTokens`).\n  - An unknown one throws `FILTER_TOKEN_UNKNOWN`. The refusal then logged \"catalog could not be read\" and let the write through.\n  - A known one is replaced by its value, so the lookup judged a different name.\n- **The consequence.** Every object without a declared operator but with at least one key, and every brace-wrapped string, bypassed the refusal.\n\n**What changed** (head `c282e608`, `position-catalog-refusal.ts` only):\n\n- **Operator objects are left to the engine.** `judgedName` stands down on them using `isFilterOperatorObject`, a narrow mirror of the engine's module-private `filterOperatorKeysIn` built from the same spec inputs (`isPlainRecord`, no `Date`, an own key in `ALL_OPERATORS` or the retired operators). It is not a `$`-prefix test: `{ $foo: 1 }` is judged.\n- **Placeholder-shaped names are looked up literally.** `catalogCarries` handles every name `classifyFilterToken` would treat as a placeholder: it reads the catalog names that share its first character (`$startsWith`) and compares in code, under the same scoped context. Such a name is therefore judged, never resolved and never failed open.\n- **The id hint** skips placeholder-shaped values.\n- **Docblock.** The stand-down bullet now names the engine's three refusals exactly: `required`, `max_length`, and `invalid_type` for an operator object. The `stringForm` doc now says a bigint is `String(value)` and only null, undefined, a symbol, a function or an unserialisable object gives `undefined`. \"Fails open\" says a placeholder-shaped name never falls open.\n- **The changeset is unchanged**; its \"Which writes\" is still true as written.\n\n**Tests** (24 cases, up from 21). Every refusal pin asserts `code` and `status`.\n\n- An insert of `{ $in: ['x'] }` or `{ $in: [], a: 1 }` gets the engine's `VALIDATION_FAILED` / 400 with `fields[0]` `{ field: 'position', code: 'invalid_type' }`, and nothing is stored.\n- `{ a: 1 }` and `{ $foo: 1 }` are refused `reference_not_found`, with value `'{\"a\":1}'` / `'{\"$foo\":1}'`, and no \"could not be read\" warning is logged.\n- `'{nope_tok}'` and `'{current_user_id}'` are refused `reference_not_found` as themselves. A catalog row really named `'{lit_pos}'` is found, and the assignment naming it is accepted.\n\n**Ablations** at `c282e608`; every restore was proven by blob == `HEAD` and an empty `git diff HEAD`:\n\n| ablation | mutation | result |\n| --- | --- | --- |\n| O1 | operator stand-down removed | 1 red: `{\"$in\":[\"x\"]}` answered `reference_not_found` instead of `invalid_type` |\n| O2 | literal lookup removed | 2 red: `{ a: 1 }` and `'{nope_tok}'` were accepted (\"expected the write to be refused, but it succeeded\") |\n| B1 / C1 / P1 / N1 / N2 / N3 re-run | as before | 2 / 1 / 3 / 3 / 1 / 2 red |\n\n**Verification at `c282e608`**\n\n- plugin-security: 141 files / 2917 → 2920 tests passed; typecheck exit 0.\n- `dispatch-gates --commands`: 94 derived, 94 run, all exit 0. The `--ran` reconciliation reports a derived zero.\n- `check:query-options-erasure` holds at 236.\n- The diff-scoped issue-citation check: 11 citations, all resolve.\n"
    }

    Generated by Claude Code

  10. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20292 → f39ea95961. pm:dispatched 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.

    The landing readings:

    What landed for this card: B. The catalog reads take the writer's organization plus organization-less rows, in the engine probe's spelling. A name only another organization carries is refused exactly like a name no organization carries. On top of that, four patch rounds closed three shapes that had escaped the refusal:

    • non-string values, judged by their string form;
    • placeholder-shaped names, looked up literally and never token-resolved or failed open;
    • operator objects, left to the engine's own invalid_type.

    Reviewer recommendations declined for now. The filing gate (filing-gate.md) requires a measured reach:, meaning one wrong result through a public door, and none of these has one. Each is recorded here with dedupe words, so that a later measurement can file it:

    1. The invitation door. An invitation naming a catalog-less position is accepted at issuance, and its placement lands under a system context at acceptance. Dedupe words: assertIssuable position name, afterAcceptInvitation sys_user_position.
    2. Field.text accepts non-string input and stores the driver's text (SQLite: 123 → '123.0'). It was measured at the engine layer only. Dedupe words: Field.text non-string coercion, text column number stored 123.0.
    3. Brace-shaped catalog names at grant resolution. The resolver's $in over a holder's position names passes through resolveFilterTokens, so a catalog name wrapped in braces would be rewritten or refused there. sys_position.name has no pattern excluding that shape. This is a code reading. Dedupe words: sys_position name placeholder shape, resolve-authz-context $in filter token.
    4. The public-form grant path reaches this refusal with a tenant-less context, if a form is ever authored over sys_user_position. It is pre-existing, and this is a code reading.

    Generated by Claude Code

  11. added a commit that references this issue on Sep 28, 2026
    f39ea95
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

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicespriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions