Repository navigation
Description
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:
-
The warning misdescribes them.
warnPreFixOrganizationLessRowsfires 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. -
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
os-zhuang commentedon Aug 24, 2026
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_setrows, 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 currentorigin/mainfor the symptom (not the issue number) before dispatch per the shared-infrastructure rule.Generated by Claude Code
claude commentedon Aug 24, 2026
{ "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
os-zhuang commentedon Aug 24, 2026
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/corecontract 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