Skip to content

Org-scoped presentation customization authority: a tenant org admin authors tier-A overlays without platform-wide manage_metadata #12702

Description

@os-zhuang

Part of #12701 (hotcrm single-DB multi-org SaaS). Maintainer-decided direction (2026-08-27 live PM chat, direct-dispatch channel; quotes in the epic) — the implementation shape below is open, the contract is not.

The measured gap (all on origin/main @ 15bf9e8)

  • manage_metadata is the only metadata-authoring capability, and it is scope: 'platform' (packages/spec/src/security/capabilities.ts:43). Every /meta write door demands it (packages/runtime/src/domains/meta.ts, pinned by meta-save-capability-gate.test.ts).
  • Org-scoped writes are threaded only for the five registry allowOrgOverride: true types (view, dashboard, report, translation, email_template) via organizationIdForMetaWrite (packages/metadata-core/src/meta-write-org-scope.ts); every other type lands env-wide (org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 discipline).
  • Consequence in a walled posture (group/isolated): granting a tenant org admin the one key that unlocks per-org tier-A overlays also unlocks env-wide tier-B authoring (flows, objects — cross-tenant reach). So a SaaS operator cannot let tenants customize views/dashboards/reports/translations/email templates at all, even though the per-org overlay mechanism (ADR-0005) is complete under the door.

Contract to build

A tenant org admin in a walled posture can author org-scoped overlays for exactly the five tier-A types without holding platform manage_metadata:

  1. A new org-scoped authoring capability (suggested name manage_org_presentation; final name is the dev's call inside ADR-0066 conventions) declared in PLATFORM_CAPABILITIES, seeded like its siblings.
  2. The metadata write door accepts it only when ALL of: the type's registry entry declares allowOrgOverride: true; the write is org-scoped to the caller's own active organization (never env-wide, never another org); posture semantics unchanged otherwise. Platform manage_metadata keeps its exact current behavior — this capability is a subset key, not a re-keying.
  3. Refusals stay loud and name the sanctioned path (no caller-grant disclosure, The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450): a holder attempting a tier-B write or an env-wide write is refused with the existing envelopes (NOT_OVERRIDABLE / PERMISSION_DENIED family — reuse codes, do not mint).
  4. Read side: verify the org-overlay read path (organizationIdForMetaRead) needs no change for this caller class; state the conclusion in the PR body either way.

Walls (restated so the card cannot drift)

Acceptance

  • Capability declared + seeded + documented in the capability registry's own conventions.
  • Walled-posture pins in both directions: org admin holder CAN write an org-scoped view overlay; the same holder is refused (code + status asserted, ADR-0112 envelope) for a tier-B write, an env-wide write, and a foreign-org write. single-posture behavior byte-unchanged for existing callers.
  • Changeset (published packages).

Clause-②: yes (new capability = widened public surface) — dispatch and review at the contract-review tier.

Activity

  1. added
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 27, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 27, 2026
  3. self-assigned this
    on Aug 27, 2026
  4. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 1 — maintainer direct-dispatch channel (2026-08-27 live PM chat, verbatim: 「按照你的建议,创建卡片,并使用项目经理技能派发完成所有相关的开放」, then 「开发」)
    Session: session_01SVYmuhHW6qZmNBqciaS7BN
    Branch: claude/issue-12702-org-presentation-capability
    Worktree: objectstack-issue-12702
    Domain: domain:spec
    File surface: packages/spec/src/security/ (capability registry), packages/runtime/src/domains/meta.ts + its gate tests, packages/metadata-protocol/src/ (write-authorization seam), .changeset/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5 — clause-② content mandate (new capability widens the public surface); dispatch-gates run at 15bf9e8 reports no path-derived mandate and flags packages/spec/src/** as clause-② suspect surface
    Clause-②: yes
    Serial constraints cleared: none — no open in-flight claim or PR touches these paths (git log origin/main -8 -- packages/spec/src/security/capabilities.ts packages/runtime/src/domains/meta.ts packages/metadata-protocol/src/protocol.ts: latest churn is the #12508 rest package-door fix, disjoint)


    Generated by Claude Code

  5. claude commented on Aug 27, 2026

    @claude
    Contributor

    Dev claim — os-dev seat, subagent of session_01SVYmuhHW6qZmNBqciaS7BN (dispatched by the PM claim above; same shared session, dev seat distinct from the PM loop).

    Branch: claude/issue-12702-org-presentation-capability (pushed)
    Worktree: objectstack-issue-12702

    Starting implementation now.


    Generated by Claude Code


    Generated by Claude Code

  6. claude commented on Aug 27, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 12702,
      "status": "done",
      "branch": "claude/issue-12702-org-presentation-capability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12705",
      "premise_still_valid": true,
      "summary": "Implemented the org-scoped presentation authoring capability manage_org_presentation exactly per the card's contract: declared scope 'org' in PLATFORM_CAPABILITIES (auto-seeded at boot; granted by no shipped set, so single-posture defaults are unchanged), plus a shared registry-derived acceptance predicate metaWriteCapabilityVerdict in metadata-core, run by every /meta item write door on both transports (REST PUT/DELETE/publish/rollback and the dispatcher PUT). It admits the new capability ONLY for a type whose registry entry declares allowOrgOverride true AND a session with an active organization — the very organization every door threads — so tier-B, env-wide and foreign-org writes stay walled; both _migrate-stored doors deliberately stay manage_metadata-only. All three PM mechanism assumptions verified true on origin/main at 15bf9e8 (manage_metadata was the only metadata-authoring capability and platform-scoped at capabilities.ts line 43; the doors and org threading are where the card said; the read path needs no change for this caller class — organizationIdForMetaRead threads ctx.tenantId for every caller with no capability input, stated in the PR body). metadata-protocol was left untouched by design: capability decisions live at the transport doors, and the protocol's existing orgScopedWriteRefusal (NOT_OVERRIDABLE 403) is the standing second wall; the five-type identity pin is untouched.",
      "tests": "All at branch head 51bf989. Full suites green: spec 435 files/11541 tests, runtime 196/2887, rest 151/2501, metadata-core 13/218, plugin-security 86/1561, lint 81/2300 (verdict lines: 'Tests  11541 passed', 'Tests  2887 passed', 'Tests  2501 passed', 'Tests  218 passed', 'Tests  1561 passed', 'Tests  2300 passed'; every run through scripts/pm/os-verify-lock.sh, VERDICT command-exit 0). Typecheck green for the four changed packages; measured coverage: metadata-core and rest compile the new/edited test files (tsc --listFiles hits 1 and 2), runtime's tsconfig excludes the test layer repo-wide (pre-existing debt-ratchet posture) so its new cases are exercised by vitest execution, not tsc. New pins both directions: org-active holder admitted for tier-A with the threaded organizationId asserted equal to the caller's own org on all five doors; same holder refused (code+status: REST 403 FORBIDDEN, dispatcher 403 PERMISSION_DENIED) for tier-B (object, flow), env-wide (no active org), and org-id smuggling via query/body proven inert; _migrate-stored exclusion pinned. Reverse verification with direction predicted first: both door files reverted to base 15bf9e8 keeping tests+helper, mutation confirmed on disk by verdict-anchor grep counts falling to 0 in both files; no rebuild was needed for the ablation legs because both gate suites import their door via same-package relative source paths (no package-exports/dist resolution in the loop); measured 4 red (runtime suite) + 8 red (two REST suites) exactly on the predicted admission/message cases, refusal-direction cases and the 41-test helper control stayed green; restore proven by anchor counts (7/4), git hash-object equality with the HEAD blobs, and empty git diff HEAD. spec check:generated: all 14 artifacts up to date.",
      "gates": "UNION RESULT: 0 failing gate(s) of 25 at 51bf989 — run after the final commit, sha cited from that run. Set = the 21 families scripts/pm/dispatch-gates.mjs derived from the real changeset (no hand-fed paths; derivation header cites objectstack-ai/objectstack at 51bf989) plus dispatch-named check:durability-log-level, check:filter-alias-parity, check:nul-bytes, check:error-code-casing. Every dispatch-named family was either in the derived set or run explicitly. Derived-new beyond the dispatch list, run green: check:authz-resolver, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:objectql-double-limit, check:objectui-changeset, check:pm-half-states. Per-gate exits captured before any pipe (no tail-masking).",
      "files_changed": [
        ".changeset/org-scoped-presentation-capability.md",
        "packages/metadata-core/src/index.ts",
        "packages/metadata-core/src/meta-write-capability.test.ts",
        "packages/metadata-core/src/meta-write-capability.ts",
        "packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts",
        "packages/rest/src/meta-item-save-capability-gate.test.ts",
        "packages/rest/src/meta-write-door-capability-enumeration.test.ts",
        "packages/rest/src/rest-route-ledger.ts",
        "packages/rest/src/rest-server.ts",
        "packages/runtime/src/domains/meta-save-capability-gate.test.ts",
        "packages/runtime/src/domains/meta.ts",
        "packages/runtime/src/route-ledger.ts",
        "packages/spec/src/security/capabilities.ts"
      ],
      "deviations": [
        "metadata-protocol untouched despite being in the card's expected file surface: the capability decision belongs to the transport doors (the protocol deliberately owns none), and its existing orgScopedWriteRefusal already walls org-scoped non-tier-A writes below the door; landing points named in the PR body.",
        "Acceptance applied to all four REST item doors (save, reset, publish, rollback), not the save door alone: they are one authoring family behind one policy, each already threads organizationIdForMetaWrite (so the same conditions bound the blast radius to the caller's own org partition), and save's draft mode makes publish the loop's second half. Flagged for contract-review attention since the card's acceptance text names only the write door.",
        "Tier-A refusal message text extended to name the new sanctioned path (the card requires loud refusals naming it); tier-B refusal sentences stay byte-identical, and no test pinned the previous texts. Refusal codes/statuses unchanged on both transports.",
        "Collateral test fix: plugin-security bootstrap-system-capabilities.test.ts hard-coded truncation-lost curated names as indices 6/7 of an 8-entry list; now derived with slice(-2) since the list has 9 entries.",
        "Channel: gh CLI absent in this container; repo-scoped REST probed 200 and used for all GitHub reads/writes; bulk issue reads used the zero-quota issue-page embedded-JSON channel."
      ],
      "mcp_calls": "0 — no MCP GitHub calls; claim comment, PR creation, and all read-backs went through repo-scoped REST after a green probe, and the issue/epic bodies came from the zero-quota page-payload channel",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  7. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Aug 27, 2026
  8. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    ContributorAuthor

    Review of record — PM ACCEPT + clause-② contract review PASS, both for PR #12705 at head 51bf989.

    Contract-review chain (needs:contract-review hung on both carriers this stroke, reviewed, cleared same-stroke below): eligibility per the downgrade fuse's machine reading — this seat's external_metadata.last_served_model = claude-fable-5 = CONTRACT_REVIEW_TIER (dispatch-seat self-review path, 2026-08-21 ruling). Contract increment read from the diff itself, not the report:

    • PLATFORM_CAPABILITIES gains manage_org_presentation (scope: 'org'), granted by no shipped set — single-posture deployments unchanged by its existence; the entry's comment forbids PLATFORM_ADMIN_ONLY_CAPABILITIES membership. Declared AND enforced in the same PR (no paper capability).
    • One shared predicate (metaWriteCapabilityVerdict, metadata-core) run by all five /meta item write doors on both transports; admits the new key only for declaresOrgOverride(canonicalType) AND a non-empty caller-own active org — the same value each door threads into organizationIdForMetaWrite, so an admitted write cannot land env-wide or in a foreign org by construction. _migrate-stored on both transports stays manage_metadata-only. Refusal envelopes unchanged (REST 403 FORBIDDEN, dispatcher 403 PERMISSION_DENIED); the common tier-B sentence byte-identical; org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 five-type identity pin untouched.
    • The one deviation the dev flagged for this review — acceptance on all four REST authoring verbs (save/reset/publish/rollback), not save alone — is judged in contract: the verbs are one authoring family over the same org-scoped overlay row, and each door's blast radius is bounded to the caller's own org partition (reset drops only the caller's own overlay, publish promotes only that partition's draft, rollback restores only its history). No widening beyond the ruled direction.

    PM checklist: draft ✓ base main ✓ Fixes #12702 first line, no other closure keywords ✓ scope = 13 files matching the report, deviations (metadata-core as predicate home, rest as REST-door producer, plugin-security collateral test fix) all declared with landing-point reasoning ✓ changeset present (4 published packages, minor) ✓ rejection pins assert code+status on both transports (spot-checked in the diff) ✓ reverse verification predicted-then-measured with on-disk mutation/restore proof ✓ gate union self-reported 0/25 at 51bf989, to be superseded by CI job conclusions before enqueue.

    Landing: waits on full CI green on 51bf989 (Lint & Repo Gates, Type Check · workspace, Test Core shards, Dogfood gates still in progress at review time — a converging draft is the dispatch contract's normal state). On full green: ready → merge queue, per the clause-② enqueue gate (dispatched AND reviewed at CONTRACT_REVIEW_TIER; path limb packages/spec/src/** satisfied).


    Generated by Claude Code

  9. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions