Skip to content

sys_capability.active is read by nothing — the Deactivate action tells the admin grants stop resolving, and they do not #8535

Description

@os-zhuang

Found while implementing #8470, which required establishing whether any runtime path resolves a sys_capability row (as opposed to the bare capability name string). The answer is no — and that answer has a consequence beyond #8470.

The claim the product makes

sys-capability.object.ts ships a deactivate_capability record action whose confirmation dialog reads, verbatim:

Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.

The active field is also surfaced in all three list views and in highlightFields, so deactivation presents as a first-class administrative control.

What actually happens

Nothing reads it. Measured on origin/main at 30f1b7488d:

  • The AND-gate that enforces requiredPermissions compares string sets. PermissionEvaluator.getSystemPermissions() unions permissionSets[].systemPermissions (strings); normalizeRequiredPermissions reads the resource's own declared strings. Neither loads a row.
  • A repo-wide search for row reads of the object — find/findOne/count/aggregate against 'sys_capability', plus every consumer of the platform-object-names entry — returns exactly two production call sites, both seeders: bootstrap-system-capabilities.ts and bootstrap-declared-capabilities.ts. Both read name, managed_by, package_id. Neither reads active, and neither is on an authorization path.
  • validateCapabilityReferences (the authoring lint) resolves against PLATFORM_CAPABILITY_NAMES, stack declarations and seed-data records — the spec constant and the config, never the table.

So active: false changes no authorization decision, no lint verdict, and no runtime resolution. It changes a column value and a badge in Setup.

Why this is worth a card rather than a shrug

The direction of the falsehood is the bad one. An admin who wants to withdraw a capability is offered a control that states, in a confirmation dialog, that withdrawing it takes effect — and the withdrawal silently does not happen. That is a security-relevant misstatement about a security surface even though it is not itself a privilege escalation: the escalation is what the admin believes they prevented.

This is also an ADR-0049 enforce-or-remove shape: a declared field with a declared meaning and no enforcement.

The two directions, not a recommendation

I am deliberately not choosing here, because the choice is a contract decision:

  1. Enforce it — capability resolution consults the row and an inactive capability stops satisfying requiredPermissions. This makes the registry load-bearing on the authorization hot path, which is a real architectural change (every gate needs the row, with the caching and fail-closed questions that implies) and it gives an org-authored row influence over a platform capability unless the ownership scoping from bootstrapSystemCapabilities reconciles an arbitrary row when a curated capability name exists in more than one organization — find(..., limit: 1) has no ORDER BY, so the platform's own row can be left unseeded #8470 is carried through.
  2. Remove the claim — drop the action and the field, or reword the dialog to say what deactivation actually does (a catalogue/visibility flag). Cheapest, and honest.

Filed unassigned. Related to #8470, which fixes a different defect in the same seeder and does not touch this.


Generated by Claude Code

Activity

  1. added theissue type on Aug 13, 2026
  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage → needs-user-decision + security + domain:metadata (likeliest landing: sys-capability.object.ts in packages/platform-objects; the enforce option would instead land across the authz gates). Type: Bug — a shipped confirmation dialog declares behaviour ("grants … stop resolving") that no code path enforces: declared ≠ enforced.

    Human floor, not auto-adjudicable: security/permission boundary, and the cheap option removes a published capability — both are hard floors regardless of prism alignment.

    Premises (re-check before ruling):

    Question: enforce sys_capability.active on the authorization path, or remove/reword the Deactivate claim?

    Options:

    Four prisms:

    1. Platform long-term coherence: B shrinks a false surface; A adds a second authorization source of truth alongside string sets — a real architecture change that deserves its own ADR if ever wanted, not a bug-fix rider.
    2. Measured business pull: zero — nothing reads active, and the failure is silent by construction, so no one has hit it. Pull for A is speculative capability-lifecycle management.
    3. AI-agent error-resistance: today's shape is the canonical trap — a declared control the runtime does not honour. B restores declared = enforced by deleting the declaration; A restores it by building enforcement. Both beat the status quo; B is structurally harder to get wrong.
    4. Startup scope discipline: B removes; A is declare-and-maintain on the hottest path in the system.

    Recommendation: B — remove the action and the active field (or reword to an explicit catalogue flag if Setup wants the badge), per ADR-0049 enforce-or-remove. If enforcement is ever wanted, it arrives as its own ADR with the fail-closed/caching questions answered, on top of #8470's ownership scoping.

    Dependency note: finding #8536's re-grade is gated on this ruling (its unseeded-platform-bucket severity flips with the direction chosen).


    Generated by Claude Code

  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Maintainer ruling recorded — option B: remove the false claim (ADR-0049 enforce-or-remove). Provenance: maintainer live session, 2026-08-13 ~23:50Z, verbatim 「接受你的全部建议」, accepting this seat's four-prism briefing of the full decision box (this card's briefed recommendation was B).

    The ruling, spelled out for dispatch:

    1. Withdraw the Deactivate action's promise. Default implementation shape: reword the confirmation dialog and field semantics to what active actually is today (a catalogue/visibility flag with no authorization effect), and demote it from highlightFields/list-view prominence accordingly. Full removal of the action + field is the alternative shape — if the lane prefers it, it goes through the spec-property-retirement playbook (ADR-0049/0087 discipline), not an ad-hoc delete.
    2. Option 1 (enforce) is rejected for now: putting the registry on the authorization hot path is an architectural change (caching, fail-closed, org-authored rows influencing platform capabilities) that must arrive as a designed feature with its own card if capability lifecycle management ever earns real pull — not as a side effect of wiring up one field.
    3. Either shape must leave no surface claiming deactivation affects grant resolution — dialog text, field description, and generated reference pages all checked at fix time.

    Label transition: needs-user-decision → pm:queue. Type Bug stands (a published claim contradicting enforced behaviour).

    Consequence for #8536: its first named re-grade trigger (this ruling) has fired — re-graded in a separate comment there.

    Recorded by the triage seat on the maintainer's direct channel.


    Generated by Claude Code

  4. self-assigned this
    on Aug 14, 2026
  5. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    Claim — dev seat starting implementation.

    • Session: session_012WMpuAfA2KSdDjGF6tm1bH
    • Branch: claude/issue-8535-deactivate-claim (worktree ../objectstack-issue-8535, off b45c71e85a)
    • Shape: R1 reword (option B, default shape). Not the retirement playbook — reasoning will be in the PR.

    File surface I expect to touch (declared up front so parallel seats can see overlap):

    • packages/plugins/plugin-security/src/objects/sys-capability.object.ts — deactivate_capability.confirmText, active field description, highlightFields, the three listViews[*].columns
    • packages/plugins/plugin-security/src/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts — leaf values only, all four locales carry a translated copy of the claim
    • a new test file under packages/plugins/plugin-security/src/ — the no-surface-claims-this sweep
    • .changeset/*.md

    Disjointness with the live sibling seats:

    • #8552 holds the sys_capability authoring write door (curated-name refusal at create/rename). I touch no validation path and no seeder — only presentation metadata on the same object file plus its bundles. If I find myself in a shared validation path I stop and escalate rather than race it.
    • #8577 was told to check before touching plugin-security/src/translations/*. I am taking those four bundle files for the sys_capability._actions.deactivate_capability.confirmText and fields.active.help leaves. Flagging explicitly.
    • #8470 (5c04b2a9, PR fix(security): the curated capability seeder reconciles the row the platform owns, not whichever row shares the name (#8470) #8537) has landed; its curated-lookup scoping and blockedCurated are in bootstrap-system-capabilities.ts, which I do not edit.

    Premise re-verification (measured on origin/main @ b45c71e85a, not inherited):

    1. No reader of active — still true. Two production writers (bootstrap-declared-capabilities.ts:230, bootstrap-system-capabilities.ts:258), both active: true on insert; zero readers. getSystemPermissions unions permissionSets[].systemPermissions strings; the lint resolves seed rows by name only.
    2. Dialog text still ships — packages/plugins/plugin-security/src/objects/sys-capability.object.ts:72, plus a translated copy in all four shipped locales.

    Generated by Claude Code

  6. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8535,
      "status": "done",
      "branch": "claude/issue-8535-deactivate-claim",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8619",
      "premise_still_valid": true,
      "summary": "Both premises re-verified on origin/main at b45c71e85a, not inherited. (1) No reader of `active` exists: getSystemPermissions() unions permissionSets[].systemPermissions as plain strings and requiredPermissions is matched against that string set — no row load; the table's only two production readers are the seeders, which WRITE active:true on insert and never read it back. (2) The dialog text still shipped, in the source AND in all four locales. Took the reword shape (R1 default), not the retirement playbook: that playbook governs authorable packages/spec properties (liveness ledger, ADR-0087 conversion, retiredKey() tombstones), whereas sys_capability.active is a shipped DB column with its own index on a plugin-owned system object, written by two seeders — removal is a data migration, and it would touch bootstrap-system-capabilities.ts, which #8470 landed in and which this PR leaves untouched. Changed: dialog now states the non-effect outright rather than merely omitting the promise; the same correction in all four locales; `active` gains a description it never had (its absence is how the dialog became the only place the field's meaning was stated); demoted from highlightFields, from the `danger` variant, and from the two scoped list views, kept in all_capabilities. The i18n trap fired exactly as warned and is documented in the PR: `--write` ADDED the new active.help key to all four bundles but left the CHANGED confirmText stale in all four including en, so the eight leaf values were hand-corrected. check:i18n reports 'in sync' either way, so it is not evidence here — the sweep test is.",
      "tests": "New sweep `sys-capability-deactivation-claim.test.ts` (19 cases): walks every string in the object definition and in all four locale bundles, applying a NEGATIVE check (the withdrawn claim's own words, per locale, appear nowhere) and a POSITIVE one (each locale's dialog and field help actually STATE the non-effect — catches the cheap non-fix of deleting the sentence). Ablations, colour predicted before each run, all matching: old English text back in the SOURCE only -> predicted 2 red with all bundle cases green, measured exactly that; old ja-JP text back in the BUNDLE only -> predicted 2 red (ja-JP only), measured exactly that; `active` back in highlightFields -> predicted 1 red, measured 1. The first two prove the bundle assertions read the bundle files independently of the source — precisely the blind spot that let a corrected source ship beside a stale en bundle in the #8601 case. Non-vacuity also pinned by case count (1+4+1+4+1+4+1+2+1 = 19), so no it.each silently expanded to zero. Fix committed BEFORE the ablations so each restore came out of a real commit; tree verified clean against the commit after each. Gates green after the final commit: plugin-security 57 files / 1103 tests passed, `tsc --noEmit` clean; check:i18n 'OK (9 packages — all bundles in sync)' on a BUILT CLI; check:nul-bytes OK (7759 files) plus a control-byte self-scan of every touched file; check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-dispatch-gates, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. check:type-check-debt run in all three modes: plain, --self-test, and --re-measure — 'OK, 33 ledger entries re-measured, none above its recorded number; surplus: none', i.e. no ledger growth. --re-measure first failed on a stated PREREQUISITE (an unbuilt nested closure, @objectstack/service-knowledge); rebuilt with lint.yml's exact filters ('./packages/*' + './packages/*/*') and re-ran green — that first result meant nothing, as the script's own error text says.",
      "open_questions": [
        {
          "question": "How far should the demotion from list-view prominence go? The ruling says 'demote from highlightFields / list-view prominence accordingly' without fixing the boundary, and I made a judgment call inside it.",
          "options": [
            "A (what I shipped) — out of highlightFields, out of the `danger` variant, out of the two SCOPED views (platform, org); kept in all_capabilities, the full-catalogue view.",
            "B (stricter) — also drop it from all_capabilities, so `active` is settable via the action but appears in no grid.",
            "C (further) — also rename the field LABEL from 'Active' to something non-authorization-sounding (e.g. 'Listed'), which would add four more locale strings."
          ],
          "recommendation": "A, because a catalogue attribute belongs in the catalogue view and stays observable and filterable for the admin who sets it; B is the opposite error — a flag the product lets you set but never lets you see is its own dishonest surface. I did not take C: 'Active' is not itself a claim about grant resolution (the dialog was), and a label rename ripples four locales for little gain. Both are cheap to change if you read the ruling more strictly — the demotion is pinned by tests, so flipping it is a visible, reviewable edit."
        }
      ],
      "out_of_scope_findings": [
        "filed as #8613: `sys_permission_set.active` and `sys_position.active` are unenforced in exactly the same way, and both Deactivate dialogs promise access stops — no `active` predicate exists anywhere in the resolution chain (resolvePermissionSetsForContext, resolvePermissionSets' three sources, or the DB loader's `where`, which also does not carry `active` onto the mapped PermissionSet). Worse there than here, since a permission set is the grant itself. Filed unassigned, unlabeled for triage; its direction is deliberately NOT assumed to follow this card's ruling, since unlike a capability catalogue it has a plausible enforcement point and plausible business pull."
      ]
    }

    Not asked about, worth stating:


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions