Skip to content

A FRESH walled deployment still mints 8 org-less sys_permission_set rows, and the per-organization pass then reports them as "pre-fix" leftovers with a remedy that recreates them #11532

Closed

Description

@baozhoutao

Measured while re-verifying cloud#1406 (the downstream acceptance leg for the ruled Option C landing, objectstack#11121). The acceptance itself passes — this is a separate, smaller thing noticed in the same run, filed rather than folded in.

What was measured

Fresh boot of the walled HotCRM SaaS rig (scripts/dev-local/run-hotcrm-saas.sh --seed, OS_TENANCY_POSTURE=isolated, three organizations, one SQLite file), framework at cloud's pin 41ae8d68 — which contains objectstack#11121 (REST compare against merge commit 504c8d55: status: ahead, behind_by: 0).

Catalog storage after the boot:

sys_position                 total 54   org-less  0   (18 x 3 orgs)
sys_sharing_rule             total 30   org-less  0   (10 x 3 orgs)
sys_position_permission_set  total  3   org-less  0   ( 1 x 3 orgs)
sys_permission_set           total 50   org-less  8   (14 x 3 orgs + 8)

The 8 org-less rows all carry managed_by = 'platform', package_id = NULL:

admin_full_access · organization_admin · organization_admin_no_bypass · member_default
viewer_readonly · mcp_agent_data_read · mcp_agent_data_write · mcp_agent_restricted

They are written before any organization exists — the same clock argument cloud#1406 was filed on:

org-less sys_permission_set  created_at   02:15:51.702Z … 02:15:51.717Z
sys_organization             min(created_at)          02:15:53.020Z    (+1.3s)
stamped  sys_permission_set  created_at   02:15:53.125Z … 02:17:20.468Z

The writer is the platform-admin bootstrap, not one of the four seeders objectstack#11121 converted — bootstrap-platform-admin.ts seeds a sys_permission_set row per defaultPermissionSets, and it was not in that PR's file surface. Its own log line names the count:

02:15:51.749Z INFO [security] platform bootstrap complete {"seeded":8,"adminPromoted":false,"reason":"no_users","resynced":0,"resyncSkipped":0}

Why this is worth a card

Nothing is broken for a reader: the per-organization pass independently materializes all 8 of those names into every organization (under package_id = com.objectstack.plugin-security), so every principal reads its own complete catalog. The 8 org-less rows are unreadable through the wall and are the ruled Q1 outcome (kept, warned, not reaped). This card is not asking for them to be reaped.

Two smaller things do not hold up:

  1. The warning misdescribes them. warnPreFixOrganizationLessRows fires once per organization (confirmed — 3 times on this boot) and says "pre-fix organization-less … rows are still present". On a fresh deployment there are no pre-fix rows; these were minted 1.3 s earlier by the code that is warning about them. An operator reading it is told they are carrying legacy state they never had.

  2. Its remedy cannot work here. The message offers "re-initialize the deployment, or adopt each row by hand". Re-initializing a fresh walled deployment recreates exactly these 8 rows on the next boot, so the first branch is a loop. Only the hand-adoption branch terminates, and it is the one an operator is least likely to pick.

Related, and the reason this reads as a gap rather than a wording nit: per-organization-catalog.ts's own header states that "a fresh walled deployment never mints an organization-less catalog row once these seeders run per organization, so there is nothing to migrate." On the measured boot it mints 8, from a fifth seeder outside the four that were converted.

Possible shapes (not a recommendation — severity is triage's call)

  • Route bootstrapPlatformAdmin's permission-set seeding through the same per-organization pass as the other four, so the platform bucket stops being minted org-less on walled postures; or
  • Leave the rows (as ruled) and make the warning distinguish freshly minted by this boot from pre-fix leftover, with a remedy that matches which one it found.

The first changes behaviour under a ruling that deliberately kept these rows, so it should not be taken as obvious.

Refs: cloud#1406 (the acceptance this came out of), objectstack#10103 (the Option C ruling), objectstack#11121 (the landing).

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 24, 2026
  2. added theissue type on Aug 24, 2026
  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Triage (Routine seat, hourly round): → pm:queue + domain:services, type Bug.

    Rationale: lands in the security/permission-set seeding path (plugin-security / services lane). A FRESH walled deployment minting 8 org-less sys_permission_set rows, then reporting its own output as "pre-fix leftovers" with a remedy that recreates them, is a live seeding defect plus a self-falsifying diagnostic — walled-tenancy family adjacent to #1406/#11211 work, so the services seat should check current origin/main for the symptom (not the issue number) before dispatch per the shared-infrastructure rule.


    Generated by Claude Code

  4. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11532,
      "status": "done",
      "branch": "claude/issue-11532-walled-orgless-permission-sets",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11629",
      "premise_still_valid": true,
      "summary": "SYMPTOM RE-MEASURED ON CURRENT main (2cf5a96cc7), not a card-number check: a fresh walled boot (real ObjectQL + SqlDriver, OS_TENANCY_POSTURE=isolated, 3 orgs, shipped seeders in security-plugin.ts's boot order) still mints exactly 8 org-less sys_permission_set rows -- admin_full_access, mcp_agent_data_read, mcp_agent_data_write, mcp_agent_restricted, member_default, organization_admin, organization_admin_no_bypass, viewer_readonly -- all managed_by='platform', package_id=NULL, and the per-organization pass still calls them 'pre-fix organization-less rows' with the remedy 're-initialize the deployment, or adopt each row by hand'. ⚠️ ONLY DEFECT 2 IS FIXED, AND DEFECT 1 IS RULED, NOT MISSED. Stopping the minting is closed by the 2026-08-20 maintainer ruling on #10103 (Q1/Q2), verbatim: \"Q1's platform-defaults residue (bootstrapPlatformAdmin's three sets, the env-door projection) stays outside this card, unreaped and loudly warned about under walled posture; whether customers ever need those visible is a future small decision card, filed only when an onboarding flow actually wants it.\" bootstrap-declared-permissions.ts's own header records the same boundary, and the rows are load-bearing: PLATFORM_ADMIN is derived from an unscoped sys_user_permission_set grant pointing at the org-less admin_full_access row BY ROW ID (resolve-authz-context.ts s6b), which is why the ruled Option C refused a #8617-breadth reap. So the loop that WAS breakable -- it lived entirely in the remedy text -- is broken: warnPreFixOrganizationLessRows becomes warnOrganizationLessRows and splits its input into 'platform-bucket' (a name an org-less writer still seeds every boot; told plainly that re-initializing does NOT clear it and that the org's own copies were created, so no action is required) and 'pre-fix-residue' (message and remedy unchanged, verbatim), each carrying a machine-readable `origin`. Membership is by NAME, not managed_by, because the remedy turns on 'will a re-initialized deployment have this row again?'. security-plugin.ts threads the host's real bootstrapPermissionSets; the option defaults to the shipped defaultPermissionSets so a lost wiring still classifies correctly. bootstrapPlatformAdmin now declares under a walled posture that it seeded WITHOUT an organization (the rig logged {\"seeded\":8} with no hint the rows carried no org) -- seeding byte-identical. per-organization-catalog.ts's header claim that 'a fresh walled deployment never mints an organization-less catalog row' is corrected; it minted 8. PR body says `Part of #11532`, so merging leaves the card open for the minting half.",
      "tests": "All at final commit c83945d19c unless stated; every verdict read from the gate's or the lock's own printed line, never from $? after a pipe; heavy steps serialized through scripts/pm/os-verify-lock.sh. || REVERSE VERIFICATION (no mutation needed -- the pre-fix code IS the ablation, so no restore could silently fail): the new suite was written and run BEFORE any source edit, on a tree whose only difference from origin/main was the untracked test file (`git status` printed exactly `?? packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts` and `git diff --stat <BASE> -- packages/` was empty). Direction predicted in writing first -- cases 1/2/5 green (premise), 3/4 red -- then run. OBSERVED EXACTLY THAT: `Tests 2 failed | 3 passed (5)`, `AssertionError: expected undefined to be 'platform-bucket'`, `AssertionError: expected undefined to be 'pre-fix-residue'`, `os-verify-lock: VERDICT command-exit 1`. After the fix, same file: 6/6 green. || PINS ARE BY IDENTITY, NOT COUNT: case 1 asserts the org-less name set equals the 8 names literally, every row's managed_by='platform' and package_id NULL, and that each org's 9 copies are DIFFERENT row ids (managed_by='package', package_id=com.objectstack.plugin-security). Case 2 JSON-snapshots every stored column of the org-less rows before and after the per-organization sweep and requires byte-equality -- not reaped, not adopted, not re-stamped, ids unchanged. Nothing reads the pass's own verdict as evidence: assertions read rows straight off knex or the recorded logger calls. || `pnpm --filter @objectstack/plugin-security typecheck` -> `> tsc --noEmit` echoed, `VERDICT command-exit 0`. `pnpm --filter @objectstack/plugin-security test -- --maxWorkers=2` -> `Test Files 77 passed (77)` / `Tests 1470 passed (1470)`, `VERDICT command-exit 0` -- includes per-organization-catalog.test.ts case 3, which pins the UNCHANGED pre-fix wording and its 're-initialize' remedy. || `pnpm lint` (repo-wide `eslint . --no-inline-config`) -> `os-verify-lock: VERDICT command-exit 0 - held the lock 87s`. NOT narrowed. || Gate families derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-written path list; it read 8 paths from the merge base). Green at c83945d19c: check:nul-bytes, check:engine-double-contract, check:where-matcher, check:slot-lookup, check:query-options-erasure, check:type-check-coverage, check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:published-files, check:i18n, check:empty-changeset, check:adr-0087-registration; and earlier on the same source tree: check:changeset-gate-self-tests, check:objectui-changeset, check:ci-filter-parity, check:plugin-teardown-shape, scripts/docs-audit/check-affected-docs.mjs, scripts/pm/release-rehearsal-clone.mjs --self-test, check:changeset-no-major. || check:i18n FIRST returned `PREREQUISITE NOT MET -- the workspace CLI is not built` = NOT MEASURED. Built @objectstack/cli (`Tasks: 56 successful`) and re-ran: `check-i18n-bundles: OK (9 package(s) -- all bundles in sync)`. || check:engine-double-contract initially: `x RETAINED [update]: ...walled-platform-bucket-diagnostic.test.ts pins 1 engine double(s) that the pinned ledger does not record`. The suite's delegating seam routes update() through the producer's own assertEngineUpdateDispatch, so this is the PINNED ledger growing, not the shrink-only baseline. Regenerated with the gate's own `--write` (`1 added or grown, 0 lost`), never by hand; one additive row in scripts/engine-double-contract.pinned.json. || WHAT I DID NOT MEASURE: check:type-check-debt's whole-ledger `--re-measure` (needs the entire workspace built; CI's run). Measured instead the exact quantity its ratchet compares for the one package this diff touches, reproducing the gate's own remeasureProject shape (package tsconfig with the `**/*.test.ts` exclusion dropped): `tsc --noEmit` = 11 errors, EQUAL to the recorded `@objectstack/plugin-security: { errors: 11 }`, and 0 of them in the new test file -- the ratchet cannot drift up from this change. Other ledger entries not re-measured; no path in this diff reaches them. Also not measured: a real HTTP boot of the walled HotCRM rig (the card's original instrument) -- everything here is the shipped seeders against a real engine and driver, not the rig.",
      "open_questions": [
        {
          "question": "ESCALATION, not a blocker on this PR: defect 1 (the minting) is untouched because the 2026-08-20 ruling on #10103 put it outside that landing and said it would be 'a future small decision card, filed only when an onboarding flow actually wants it'. #11532 is that trigger arriving from a different direction -- an operator-visible diagnostic problem rather than an onboarding need. Does the platform bucket now get materialized per organization, or does it stay org-less indefinitely? I did NOT file a card for it, because the ruling names the condition under which one should be filed and no onboarding flow has asked; that call is the maintainer's, not mine.",
          "options": [
            "A - Leave it as ruled: platform bucket stays org-less forever, now honestly described. Cost: zero. The rows stay unreadable through the wall, which nobody has reported as a product problem -- each org holds its own complete copy (measured: 9 rows per org).",
            "B - Route bootstrapPlatformAdmin's seeding through the per-organization pass (the card's first shape). Cost: it collides with the ROW-ID derivation. PLATFORM_ADMIN comes from an unscoped sys_user_permission_set pointing at a specific admin_full_access row id (resolve-authz-context.ts s6b), and on a fresh walled boot the platform bootstrap runs BEFORE any sys_organization row exists (measured: 1.3s ahead), so there is no organization to stamp and nothing for the cross-tenant grant to point at. Either the grant starts pointing at one tenant's row (a platform-wide grant owned by a tenant) or PLATFORM_ADMIN stops being derivable that way -- both are packages/core contract changes, wider than this card and wider than #10103's ruling.",
            "C - Narrow the platform bucket to a declared row class (#10103's Option B, the fallback that was never ruled on). Cost: makes managed_by security-relevant, which #10103 already priced as the most contract surface of the three."
          ],
          "recommendation": "A for now, and B only as its own decision card with the spec/core seat. On the four axes: (1) real business need -- none measured; the card itself says 'nothing is broken for a reader', and the per-org copies are complete, so B serves no observed operator workflow today. (2) long-term soundness -- B is the direction that retires the NULL-means-global sentinel, which #10103 ruled correct in principle; but doing it without resolving the row-id derivation would be a workaround, and the derivation lives in packages/core, outside this card's fence. (3) making AI-written metadata hard to get wrong -- neutral either way: no authoring surface reads these rows; the honest diagnostic this PR lands is the part that helps a reader. (4) startup scope discipline -- decisive for A: this is capability expansion with no pull, against a standing ruling, on a security-semantics path that already cost one full decision round."
        },
        {
          "question": "The PM brief said 'Address the minting; the diagnostic's behaviour follows from it', which contradicts the 2026-08-20 maintainer ruling quoted above. Flagging rather than silently picking a side, per the standing clause that a conflict is reported, not resolved by the dev. I took the maintainer ruling as controlling and delivered the diagnostic half only, with `Part of #11532` so merging cannot close the card.",
          "options": [
            "A - Accept as delivered: diagnostic fixed, minting left as ruled, card stays open for the minting half.",
            "B - Reopen the minting half now as its own card against the spec/core seat (the row-id derivation is the real work, and it is in packages/core)."
          ],
          "recommendation": "A. B is not refused, only sequenced: the ruling that defers it is 4 days old and no new evidence contradicts its premise. If B is wanted, it needs the spec/core seat, not this one -- resolve-authz-context.ts is outside this card's fence."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 24, 2026
  6. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — A: the minting stays as ruled; closing

    Provenance: maintainer, 2026-08-24, live PM chat, verbatim: 「接受你的建议。」 on the dev's aligned four-axis recommendation (A) relayed in the batch review.

    Ruled: the platform bucket stays org-less, exactly per the standing 2026-08-20 #10103 ruling — now honestly described by the diagnostic PR #11629 landed (re-initializing does not clear it; no action required; each org holds its own complete 9-row copy). The #10103 trigger ("filed only when an onboarding flow actually wants it") is judged not fired by this card — an operator-visible diagnostic problem is not an onboarding pull, and the diagnostic problem itself is now fixed. Option B (per-organization materialization) remains available as its own future decision card with the spec/core seat if a real onboarding pull arrives; the dev's measured constraint (PLATFORM_ADMIN row-id derivation + bootstrap-before-first-org, both packages/core contract territory) is the pricing input for that day.

    Closing as completed: defect 2 (self-falsifying diagnostic) fixed and merged; defect 1 (minting) resolved by ruling as intended behaviour, honestly surfaced.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions