Petticoat

Petticoat turns existing CanCanCan abilities into a small, deterministic catalogue for frontend controls and navigation. It does not authorize requests. The server must still check every operation.

The core is application-independent. It does not load Rails, build users, discover abilities, query a database or run permission blocks.

Supported environment

Version 0.2.0 is tested with Ruby 4.0.6 and this CanCanCan 3.6.0 fork: olistik/cancancan at 18ce8ad800658629d4c1c2cb1382cd4794c2a5a3. The adapter checks the relevant source files as well as the version number. Upstream CanCanCan 3.6.0 and other builds are not verified and may be rejected. Do not bypass the check to make an incompatible build appear supported.

The development Gemfile pins that fork. Applications must select it themselves; a gem dependency cannot select another gem's Git source.

One selected population per document

Callers supply completed ability objects and meaningful subject identifiers. Keep users with different construction contexts separate. For example, compile privileged and ordinary users into separate documents when a flag changes rule construction. Petticoat does not know what your application's flags mean.

Here is a complete, database-free example:

require 'petticoat'

Item = Struct.new(:owner)
ability = Class.new { include CanCan::Ability }.new
ability.can :read, Item, owner: 42

document = Petticoat::Document.new(subjects: { 'items' => Item })
document.add(context: 'editor', role: 'member', scenario: 'ordinary',
             snapshot: Petticoat::Snapshot.new(ability))

catalogue = document.to_h
Petticoat::Schema.validate!(catalogue)
puts Petticoat::Document.lookup(catalogue, context: 'editor',
                               role: 'member', subject: 'items')
# possible

The owner value is not exported. A conditional grant does not promise that an accessible persisted record exists.

The resource context looks like:

{
  "scope": "individual",
  "examined_roles": ["member"],
  "resources": {
    "items": {"possible": ["member"], "unknown": []}
  }
}

A document's subjects is an array of opaque identifiers, not JSON:API metadata. Do not infer identifiers by pluralizing model names. The application owns that mapping and any application-specific envelope.

Reading answers

The default view: resources answers whether some interaction may be available:

  • possible: every observed setup has some potentially permitted action.
  • denied: all observed setups deny all actions in this scope.
  • unknown: a failed capture, unsupported behavior, disagreement or missing coverage prevents a conclusion.

Resource entries contain sorted, disjoint possible and unknown role lists. An examined role absent from both lists defaults to denied. A wholly denied resource is omitted. An unexamined role, missing context or unregistered subject is unknown, not denied. examined_roles includes failed attempts; examined does not mean successful.

For action-specific controls:

actions = document.to_h(view: 'actions')
Petticoat::Document.lookup(actions, context: 'editor', role: 'member',
                          subject: 'items', action: 'read')
# conditional

The action view uses contexts[context].roles[role][subject][action] and returns unconditional, conditional, denied or unknown. Read an explicit action first, then its action *, then the scoped denied default. Explicit exceptions win. Aliases are already expanded directionally: index does not imply literal read. Custom actions keep their names. A registered subject * represents CanCanCan's :all; it is not a fallback for unknown subjects.

Resource queries omit action; action queries require it. Unknown versions, missing dimensions and extra query dimensions (including admin or scenario) return unknown. Use separate documents to select a population; lookup does not guess or merge one.

Within each setup, any supported unconditional or conditional action makes the resource possible. Otherwise an unknown action makes it unknown; otherwise denied. Only then are resource answers compared across setups with the same role/context. Two setups may allow different actions yet agree that some interaction is possible. This is not evidence that listing, reading, or any particular endpoint will work.

Capturing and validating

Document#add(context:, role:, scenario:, snapshot:, scope: 'individual') records a captured setup. Use scope: 'composed' for an actual ordered ability composition built by the caller. Never manufacture a global union of abilities. Duplicate scenario identities within a context and conflicting scope raise errors.

A caller that has established a failed or unsupported setup may add snapshot: nil; it remains unknown for every registered subject. Unexpected execution failures should abort the application's export, preserving valid output.

Document#evidence returns sorted, redacted per-scenario action observations. Snapshot#evidence(identifier_callable) returns redacted rule evidence in its original order. Neither exports condition values, SQL or record inspect strings. Condition hashes, ordinary blocks and attribute restrictions remain conditional as justified by rule order. SQL, instance subjects and custom matching can be unknown. Stored blocks are never executed merely to export them.

Schema.validate! checks the generic schema, related membership/subject coverage and the content digest. Invalid data raises an error. Schema.validate_contexts!(contexts, subjects:, view:) shares cross-field checks with applications that have already schema-validated their own envelope.

Petticoat.canonical_json(value) sorts maps, not ordered rule arrays. Document digests exclude themselves. The caller owns source fingerprints, output paths, safe file replacement and freshness checks. Keep private diagnostics separate from frontend data.

Install the optional CLI

The core above works without the CLI. require 'petticoat' does not load the installer or an application. Bundler exposes the gem's exe/petticoat as bundle exec petticoat.

Add Petticoat to your development/test bundle with require: false. Select the supported CanCanCan source and install the bundle first. From the application root, run:

BUNDLE_FROZEN=true bundle exec petticoat install --dry-run
BUNDLE_FROZEN=true bundle exec petticoat install

This creates just two application-owned files:

  • bin/petticoat: a thin, executable Bundler wrapper that loads the gem executable.
  • config/petticoat.rb: a commented integration entry to connect your own runner.

The starter is deliberately not ready to export. Until you configure the runner, export, check and test fail with an actionable error and create no catalogue. The installer does not infer roles, copy policies, configure databases, select a test framework, run factories or boot your app. It never edits Gemfile, the lockfile or existing application files.

Missing files are created; identical bytes and modes are left alone. Any content or mode conflict, symlink or unsafe path aborts before writes. Dry-run writes nothing. There is no --preset, --force or upgrade mode. Caught publication failures roll back only unchanged files created by that attempt. Installation is exclusive, not crash-atomic; rerun to finish an identical partial installation.

Freeze the bundle after dependency setup: otherwise Bundler itself can touch the lockfile before a dry run reaches the installer. The wrapper and executable also freeze their own setup.

Connect your application

Edit config/petticoat.rb: require your application-owned runner and assign it to Petticoat::CLI.integration. The generated comments show the shape. This is trusted command configuration, not a framework initializer. Only operational commands load it; help, version and install do not.

The runner can be a module, object or lambda that implements:

def self.call(mode:, arguments:, options:, out:)
  # Run your application's build, comparison or tests.
  # Raise on failure, including a failed child test process.
end
Keyword What the CLI supplies
mode "export", "check" or "test".
arguments Unparsed application test arguments; empty for export/check.
options[:output] Absolute path from an explicit --output PATH, if supplied.
options[:coverage] Absolute path from an explicit --diagnostics PATH, if supplied.
options[:seed] Integer from --seed N, if supplied.
out The output IO for status messages.

No paths, seed or test framework defaults are invented by the gem. The runner must raise on failure; returning false or a nonzero number does not signal an exit status. Normal completion means CLI exit 0. Exceptions produce exit 1; unexpected exception messages are redacted. Use Petticoat::Error only for safe, non-sensitive user-facing messages.

Your runner owns the parts that depend on your application:

  1. Establish isolated test resources and disable live calls/delivery before booting a framework or constructing factory records. A test environment name alone does not prove a database is safe.
  2. Build real, asserted scenarios and actual ordered ability compositions. Supply subject identifiers and keep distinct construction populations separate. Pass completed abilities to Snapshot and Document; do not copy permissions.
  3. Clean up scenario effects. Preserve known gaps as unknown; abort unexpected failures. Validate the complete catalogue before safely replacing any output.
  4. Implement check as a non-writing comparison, and test using your own test framework and chosen scope. Keep coverage diagnostics out of frontend assets.

After implementing and testing that boundary:

bin/petticoat test
bin/petticoat export
bin/petticoat check

Export/check also accept --output PATH, --diagnostics PATH and --seed N. The CLI dispatches these operations; it does not implement database isolation, file publication or freshness for you.

Gem: bin wrapper template + CLI + capture / reduction / lookup / schema
                              |
                    loads config/petticoat.rb
                              |
App: explicit runner + scenarios / composition / subjects / isolation / output
                              |
                    explicit build using the core
                              |
Frontend: generated JSON hints     Server: existing authorization checks

Commit the wrapper, completed config, application runner, its tests and any generated assets in the application repository. Keep application-specific onboarding and safety instructions there too. A fresh application needs its own integration; the gem cannot safely infer one.

For upgrades, update the dependency and review your runner against this contract. Do not rerun install over a customized config. Application integrations are not bundled presets and are never overwritten by a gem update. Ordinary Ability changes need a fresh export, not parallel permission declarations. Add scenarios when new roles, construction-time branches or composition contexts require them.

Develop and build locally

Use the Ruby version in .ruby-version:

bundle install --local
bundle exec rspec
bundle exec rubocop
gem build petticoat.gemspec

If a locked dependency is not installed locally, dependency setup may need network access. Tests use real CanCanCan and synthetic subjects, not Rails or factories. They also execute the Ruby example above and test the optional CLI and installer against filesystem failures and an application-supplied callable in a fresh process. Application-level factory, isolation and publication tests belong to each application.

This is a pre-release, Git-sourced gem, not a published RubyGems release. No open-source license or public release is provided; Nonstandard metadata does not grant redistribution rights. Publication is deliberately disabled through an invalid allowed_push_host. Choose licensing, verify the name, and authorize distribution separately before any release.

Made with ❤️ by olistik