Skip to content

plugin-security: sys_user_permission_set.granted_by is a plain lookup, so a granter row that no longer resolves is filed as a dangling business reference and keeps cloud's hourly integrity WARN red #22201

Description

@objectstack-fleet

Filing gate: ① a product defect, reach measured. Cloud's production control plane raises the hourly [integrity] stored references that resolve to nothing (#4551) WARN on every run. One of its findings is sys_user_permission_set#ups_d85cfc63b091b7b1.granted_by → sys_user#manual:db-cutover-superadmin, 1 of 13 rows in the 2026-09-15 reading (objectstack-ai/cloud#2274, comment 5673661010).

Found by the objectstack-ai/cloud#2274 dev (os-dev-report, objectstack-ai/cloud#2274 comment 6053481678, which carries the measurement and the draft below). Filed by the repo:cloud seat (repo:cloud#1, R45, session session_011jobP72PwN3whNm55GetXQ), because the fix lands in this repo. ⛔ Not a claim.

The defect (read at 56bf27af, cloud's pin)

  • packages/objectql/src/integrity/dangling-reference-audit.ts (about :470) routes a finding by readonly === true alone. A readonly reference that resolves to nothing goes to provenance, and the WARN condition (about :735) does not include provenance.
  • packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts:84: granted_by is Field.lookup('sys_user', { required: false, … }), not readonly. So a granter that is gone, or a sentinel, raises the alarm as a broken business reference.
  • This repo already treats the column as provenance: auto-org-admin-grant.ts:785 says "[sys_member grade changes lose the true actor — thread attribution through the better-auth seam (validity windows deliberately NOT added) #4586] The provenance the row already had a column for". It also quotes ADR-0118 D1 (:786–788): "an id or null, never a sentinel".
  • The family shares the shape:
    • sys_user_position.granted_by (sys-user-position.object.ts:106);
    • plugin-sharing sys_record_share.granted_by (sys-record-share.object.ts:230).
      Each needs its own measurement.

Fix (the dev's draft; measure first)

Not in scope: the production row holds a sentinel string. Nulling it is a production write, and it needs cloud's maintainer.

Done when

  • A granted_by that resolves to nothing is filed under provenance, and the WARN no longer fires on it.
  • A non-system caller cannot set an arbitrary granter.

Reach and timing

Reader

Triage routes it. By its package it lands in the plugin-security lane.

Dedupe

A semantic search for granted_by readonly provenance dangling reference audit sys_user_permission_set finds 27 issues, all of them adjacent and none this defect (the closest are #21861, #21669 and #21455). The dev's dedupe words are: granted_by readonly provenance, dangling-reference audit granted_by, db-cutover-superadmin and delegated-admin-gate granted_by explicit value.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p2 · domain:services · area:access · pm:queue. Direction: granted_by is provenance; the lane settles the writer question (the dev recommends A)

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T07:02Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/plugins/plugin-security (sys-user-permission-set.object.ts, sys-user-position.object.ts, delegated-admin-gate.ts) and plugin-sharing (sys-record-share.object.ts) ⇒ domain:services; rationale: plugin-security and plugin-sharing are that lane's (lanes/services.md:10). The audit in packages/objectql is not changed.

    • Why p2: one row keeps cloud's production integrity WARN red every hour (objectstack-ai/cloud#2274). A standing red alarm hides the next real finding.
    • The A / B / C question is the lane's, not a decision card.
      • This repo already calls the column provenance (auto-org-admin-grant.ts:785), and ADR-0118 D1 says "an id or null, never a sentinel".
      • A makes "granted_by = the writer" enforced. B leaves a silent NULL path. C is a consumer-side carve-out.
      • A tightens who can set the value and widens nothing. If the claiming seat reads A as moving a security boundary, it stops and asks the maintainer.
    • Family: measure sys_user_position.granted_by and sys_record_share.granted_by one by one, as the card says.
    • ⛔ The production row's sentinel is out of scope; nulling it is cloud's maintainer's write.
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T07:16Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22201-granted-by-provenance
    Worktree: objectstack-issue-22201
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Seat ruling on the lane's A / B / C (triage 6054446764): A. granted_by is readonly, and the delegated-admin gate always stamps the writer on a non-system insert.

    The four axes:

    • Business need: cloud's production integrity WARN, measured, plus ADR-0118 D1's "an id or null, never a sentinel".
    • Long-term: "granted_by = the writer" becomes enforced, not hoped for. C is a consumer-side carve-out, and B leaves a silent NULL path.
    • AI-error prevention: a caller, an AI-written app included, can no longer forge a granter.
    • Startup focus: two packages, no new gate.

    A tightens who can set the value and widens nothing, so it moves no security boundary outward. The maintainer can veto it.

    File surface at origin/main 3b493184:

    • plugin-security/src/objects/sys-user-permission-set.object.ts: granted_by (near :84) gains readonly: true.
    • plugin-security/src/delegated-admin-gate.ts: the granted_by stamps (near :645, :781, :820). On a non-system insert, the gate stamps the writer whatever the caller sent.
    • Family, measured one by one, each changed only if its writers measure as the same class:
      • plugin-security/src/objects/sys-user-position.object.ts, granted_by (near :106);
      • plugin-sharing/src/objects/sys-record-share.object.ts, granted_by (near :230), with that object's own writer stamp if it has one.
    • Read only, not edited: the system-context writers keep their value. These are invitation-placement.ts:204, auto-org-admin-grant.ts:790 and bootstrap-platform-admin.ts:1138. The last is in PR fix(plugin-security): the boot heal converges on duplicated permission-set names, and a refused existence read never inserts #22214, which is in the merge queue, in another region.
    • Tests and changesets: tests in the touched packages, plus one changeset per touched package.
      • The pins: a row whose granted_by names a deleted user lands in provenance, and the WARN does not fire. A non-system caller's explicit granted_by lands as the writer. System writers keep theirs.
      • The control: a business lookup that resolves to nothing still lands in dangling.

    Exclusions:

    • ⛔ No change to packages/objectql's integrity audit.
    • ⛔ No packages/spec.
    • ⛔ No production data write (the sentinel row is cloud's maintainer's).

    Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, a security-adjacent write path)
    Clause-②: no (narrowing)

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22201,
      "status": "done",
      "branch": "claude/issue-22201-granted-by-provenance",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/22243",
      "session": "session_01WkL6Eijt432S1Y7ekb6ovQ",
      "premise_still_valid": true,
      "summary": "Ruling A is implemented and covers both tables. sys_user_permission_set.granted_by and sys_user_position.granted_by are now readonly: true. DelegatedAdminGate.assert now runs authorize (the old body) first. It then calls stampWriter, which sets granted_by = context.userId on every row of every admitted non-system insert into the two tables, whatever the payload carried. Tenant-level admins, delegates and self-delegations are all stamped. A refused write throws before the stamp. This replaces the three conditional stamps, which ran on the delegate paths only and only when the value was null. H1 holds. The audit routes on readonly===true alone (dangling-reference-audit.ts:470), and the WARN condition (:735) leaves provenance out. granted_by sits at :105 on origin/main 3b493184, not :84; the line drifted after 56bf27af. H2 measured on the real engine (ObjectQL + SqlDriver + SecurityPlugin), before -> readonly only -> after, the same on both tables: - tenant admin naming OTHER: OTHER -> OTHER -> writer; - tenant admin naming none: null -> null -> writer; - delegate naming OTHER: OTHER -> OTHER -> writer; - delegate naming none: writer -> writer -> writer; - system insert naming OTHER: OTHER -> OTHER -> OTHER; - invitation placement apply: issuer -> issuer -> issuer; - org-admin reconcile via a system sys_member write with attributedUserId: attributed -> attributed -> attributed. H2 also falsifies one mechanism premise. On insert, readonly alone strips nothing, because the engine create-side strip skips sys_ objects (staticReadonlyInsertSubject). The gate in-place middleware stamp is what lands, and #14088 is not involved. H4 measured: a non-system UPDATE renaming the granter (admin or delegate) landed as OTHER before. Now the update strip drops it and the row keeps its granter. H5 audit buckets (inspectDanglingReferences, per table): a granted_by naming an id with no sys_user row was dangling with readonly removed (ablation B), and is provenance with zero [integrity] warnings now. The control, a user_id naming nothing, stays dangling with exactly one warning. H3: sys_user_position is the same writer class and was changed. sys_record_share is a different class and was left unchanged: managedBy engine-owned, a single SYSTEM_CTX writer stamping context.userId ?? null, and no caller-supplied granter in GrantShareInput. Merged origin/main 0e9371f0c6 before opening the PR; every reading below is at b4ed57b0ad.",
      "tests": "All at HEAD b4ed57b0ad. TESTS: - plugin-security whole package (vitest run --maxWorkers=2, under os-verify-lock): Test Files 177 passed, Tests 3766 passed | 45 skipped, VERDICT command-exit 0. - plugin-security typecheck: VERDICT command-exit 0, check:test-typecheck \"0 file(s) / 0 error(s)\". tsc --listFiles over tsconfig.test.json lists all 3 touched test files. - Build: turbo build (no docs), 72/72 tasks successful. - New granted-by-writer-provenance.test.ts: 20 passed. delegated-admin-gate.test.ts: +16 cases. - write-preview-field-gate-parity.test.ts: the fixture stand-in gains granted_by, because the gate now stamps a tenant admin insert. It was 1 red at c52e189600 and is 24/24 green after. GATES: - dispatch-gates --commands with no paths derived 65 commands. 65/65 exit 0, plus spec check:generated exit 0 (\"All 15 generated artifacts are up to date\"). - --ran reconcile: \"65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN\", with an exit code recorded per command. - check-adr-0087-registration: \"1 declared-breaking changeset(s), each carrying an ADR-0087 disposition\", not-required (no-migration-prescription). - check-changeset-no-major: \"This diff introduces no `major` bump\". The level axis is NOT APPLICABLE locally (no PR payload). - check:engine-double-contract exit 0. check:i18n \"OK (9 package(s))\". check:dual-build-cjs-loads \"106 published require entry point(s) across 66 package(s) load\". check:nul-bytes OK. - At c52e189600, the earlier run had dual-build and i18n at PREREQUISITE NOT MET (exit 3). Both were re-measured green after a full build. - ESLint narrowed to the 6 changed .ts files with --no-inline-config: the json report lists 6 files, 0 errors, 0 warnings, none ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot change the verdict on any untouched file. Repo-wide pnpm lint is left to CI. ABLATION: - Every leg used scripts/ablation-replace.mjs: anchor x1 -> x0, blob changed, restore proven blob==HEAD with git diff HEAD empty. No dist/ was in the path (relative source imports, objectql aliased to source), so the dist preflight does not apply. - A (stampWriter keeps a supplied value): 11 red, 92 green. Every \"names another user\" pin went red, e.g. Expected usr_admin, Received usr_other. Restore blob 8fb5c414ec38. - B1 (sys_user_permission_set granted_by readonly:false): 3 red (2 update pins Received usr_other; provenance pin: row in dangling), 17 green. Restore blob 2bed0a5ade90. The first B1 attempt was a no-op: the replacement was a substring of the anchor, the tool refused, and the command never ran. It was re-run. - B2 (sys_user_position readonly:false): 3 red, 17 green. Restore blob 9df5977a9179. - C (stampWriter call removed): 22 red, 81 green. Restore blob 8fb5c414ec38. NOT MEASURED (left to CI): Dogfood Regression Gate, Temporal Conformance, workspace-wide type-check lanes, Test Core shards. The 3 dogfood suites that read granted_by expect the writer or a truthy value, which this change keeps.",
      "mcp_calls": "0",
      "api_writes": "3 REST writes, all through the fleet relay (scripts/pm/fleet-write/dispatch.mjs; each stroke = one POST /repos/objectstack-ai/objectstack/dispatches 204, executed as objectstack-fleet[bot]). (1) pr_create POST /repos/objectstack-ai/objectstack/pulls -> #22243 draft, run 37750468639, read-back 9707 bytes identical. (2) label-write --issue 22243 --assign os-bill -> POST /repos/objectstack-ai/objectstack/issues/22243/assignees, run 37750534413, read-back matches. (3) this os-dev-report comment POST /repos/objectstack-ai/objectstack/issues/22201/comments. Git pushes (not REST): 6 to claude/issue-22201-granted-by-provenance (empty-branch probe, wip rig, fix, pins+changeset, fixture, merge of origin/main).",
      "files_changed": [
        ".changeset/22201-granted-by-provenance.md",
        "packages/plugins/plugin-security/src/delegated-admin-gate.ts",
        "packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts",
        "packages/plugins/plugin-security/src/objects/sys-user-position.object.ts",
        "packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts",
        "packages/plugins/plugin-security/src/delegated-admin-gate.test.ts",
        "packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts"
      ],
      "deviations": [
        "Merged origin/main (0e9371f0c6, 7 commits including plugin-security seeder changes and the Changesets pre-mode entry) into the branch before pr_create, per AGENTS.md multi-agent section 10. The branch carries one merge commit, b4ed57b0ad.",
        "The first closure build with declarations was OOM-killed (spec DTS, exit 137, other worktrees building at the same time). The measurement ran on a JS-only closure (OS_SKIP_DTS=1, separate turbo hash). Full builds with declarations were done later for typecheck and the gates.",
        "bootstrap-platform-admin.ts is not called by the new pins (read-only; PR #22214 is in flight). Its path, a plain isSystem insert, is pinned by the generic system-insert case.",
        "Field descriptions were left unchanged to avoid re-keying four translated bundles. The sys_user_position description is still true but now incomplete; this is noted in Acceptance notes.",
        "The PR body footer uses the AGENTS.md session-URL form, and commits use the model-free trailer pair. The harness reminder spelled both differently; AGENTS.md takes precedence."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · noted in the PR Family section, not filed. sys_record_share.granted_by (plugin-sharing) was left unchanged by the H3 rule (a different writer class). A granter there that no longer resolves is still filed under the audit dangling bucket. No production finding on that column was measured.",
        "carrier: 承接者:无 · noted in PR Acceptance notes, not filed. A non-system insert naming a granter is replaced silently: there is no droppedFields entry and no strip warning, because the create-side readonly strip skips sys_ objects. The writer is visible in the returned row."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #22243 at b4ed57b0 · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T08:37Z

    Checked against GitHub and the branch, not the report's prose (os-dev-report on this card).

    • Form:

      • The PR is a draft against main. Its first line is Fixes #22201, with a line-start Clause-②: no (narrowing), the claim's line. The PR assignee is os-bill.
      • Seven files, all in plugin-security: the gate, the two object files, three test files and one changeset. +481/−17.
      • No packages/spec path and the arm is no, so no contract review is owed. The PR is not governed.
      • It merges clean with main.
    • Diff, read by the seat:

      • DelegatedAdminGate.assert runs authorize first; a refused write throws and is never stamped. It then calls stampWriter, which sets granted_by to the caller's userId on every row of every admitted non-system insert into sys_user_permission_set and sys_user_position, whatever the payload carried.
      • The three old conditional stamps are gone. They covered delegate paths only, and only when the value was null.
      • Both granted_by fields gain readonly: true.
      • System writers never reach the gate, so they keep their value.
      • This is ruling A as the claim (6054701742) states it, and it widens nothing.
    • A premise falsified, accepted: readonly alone strips nothing on insert, because the create-side strip skips sys_ objects. The gate's in-place stamp is what lands, and stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 is not involved. On update, the strip drops a caller's value, as H4 measured.

    • H3, family:

      • sys_user_position measures as the same writer class and is changed.
      • sys_record_share is a different class: one system-context writer, and no caller-supplied granter in its input. It is left unchanged, as the claim's rule required.
    • Changeset, checked sentence by sentence:

      • the ! title and the BREAKING minor line, per this repo's launch-window convention;
      • the ADR-0087 disposition (not-required, no migration prescription);
      • the three "What moves" items;
      • every "Unchanged" item, including the system writers and sys_record_share.

      Each matches the diff. check-adr-0087-registration and check-changeset-no-major are green.

    • Evidence:

      • Before, readonly-only and after, on a real engine for every writer case, on both tables. A non-system named granter now lands as the writer; system and invitation writers keep theirs.
      • The audit buckets: a vanished granter goes to provenance with zero warnings, and the user_id control stays dangling with one warning.
      • Ablations A, B1, B2 and C were each red, each restored to the HEAD blob.
      • The package suite: 177 files, 3766 passed. Typecheck passes.
      • 65/65 derived gates, plus spec check:generated.
    • Carried, not filed (each in the PR's Acceptance notes or Family section):

      • A non-system insert's named granter is replaced silently: there is no droppedFields entry, because the create strip skips sys_ objects. The writer shows in the returned row.
      • sys_user_position.granted_by's description still says the gate stamps "for delegate writes". That is true but now incomplete; the dev left it rather than re-key four bundles. Carrier: the next edit of that field.
      • sys_record_share.granted_by, a granter that no longer resolves, still lands in dangling. No production finding names that column.
    • Landing to-do:

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T09:45Z.

    PR #22243 merged through the merge queue as ffb31fca. On origin/main:

    • sys_user_permission_set.granted_by and sys_user_position.granted_by are readonly;
    • DelegatedAdminGate stamps the writer on every admitted non-system insert into both tables.

    The integrity audit files a granter that no longer resolves under provenance. The PR's Fixes line closed the card completed. This note also removes pm:dispatched and the assignee.

  6. added a commit that references this issue on Oct 9, 2026
    ffb31fc
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 guardsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions