Module: Familia::Features::Relationships::ScoreEncoding
- Included in:
- ModelClassMethods
- Defined in:
- lib/familia/features/relationships/score_encoding.rb
Overview
Score encoding using bit flags for permissions
Encodes permissions as bit flags in the decimal portion of Valkey/Redis sorted set scores:
- Integer part: Unix timestamp for time-based ordering
- Decimal part: 8-bit permission flags (0-255)
Format: [timestamp].[permission_bits] Example: 1704067200.037 = Jan 1, 2024 with read(1) + write(4) + delete(32) = 37
Bit positions: 0: read - View/list items 1: append - Add new items 2: write - Modify existing items 3: edit - Edit metadata 4: configure - Change settings 5: delete - Remove items 6: transfer - Change ownership 7: admin - Full control
This allows combining permissions (read + delete without write) and efficient permission checking using bitwise operations while maintaining time-based ordering.
Flags versus roles
Four symbol namespaces exist and they are NOT interchangeable. Two are inputs that carry bits (flags, roles), one is an input that carries a mask (categories), and one is output only (tiers):
- PERMISSION_FLAGS are the atomic bits (:read, :write, :delete, ...). They are the currency of every API that inspects, mutates, or ranges over permission bits: permission?, add_permissions, remove_permissions, permission_range, score_range.
- PERMISSION_ROLES are named bundles of flags (:viewer, :editor, :moderator, :admin). They are an encode-time convenience accepted ONLY by encode_score and permission_encode, and only in the bare-symbol form: encode_score(ts, :editor) resolves the bundle, but encode_score(ts, [:editor]) does NOT -- the array form routes every element through permission_level_value like everything else, so it takes atomic flags only.
- PERMISSION_CATEGORIES are bitmasks for broad overlap queries (:readable, :content_editor, :administrator, :privileged, :owner), accepted only by category?, filter_by_category and meets_category?. They are not bundles to grant -- they answer "does this score touch this mask", and several are true at once for the same score.
- Tiers are the return values of permission_tier (:administrator, :content_editor, :viewer, :none). They are never valid input. Two of the names are borrowed from PERMISSION_CATEGORIES but mean a single exclusive bucket rather than an overlap -- see permission_tier.
Passing a role to a flag-taking method raises ArgumentError rather than resolving it -- except :admin, which names a flag too and so resolves to bit 7 (see the overlap note below). Bundles do not survive the bit operations those methods perform: permission? tests bits individually, so a bundle would answer "holds any of these" where callers mean "holds all of these", and remove_permissions(score, :editor) would silently revoke three permissions where the caller named one thing. Expand the role at the call site (PERMISSION_ROLES.fetch(:editor)) when you want its bits.
Note the deliberate overlap on :admin -- the FLAG is bit 7 (128) while the ROLE is all eight bits (255). encode_score(t, :admin) therefore grants everything, but :admin reaching a flag-taking method means bit 7 alone. Because the two differ, roles are never silently resolved in those methods: doing so would widen add_permissions(score, :admin) from one bit to all eight.
Constant Summary collapse
- MAX_METADATA =
Maximum value for metadata to preserve precision (3 decimal places) For 8-bit permission system, max value is 255
255- METADATA_PRECISION =
1000.0- PERMISSION_FLAGS =
Permission bit flags (8-bit system)
{ none: 0b00000000, # 0 - No permissions read: 0b00000001, # 1 - View/list append: 0b00000010, # 2 - Add new items write: 0b00000100, # 4 - Modify existing edit: 0b00001000, # 8 - Edit metadata configure: 0b00010000, # 16 - Change settings delete: 0b00100000, # 32 - Remove items transfer: 0b01000000, # 64 - Change ownership admin: 0b10000000, # 128 - Full control }.freeze
- PERMISSION_ROLES =
Predefined permission combinations
{ viewer: PERMISSION_FLAGS[:read], editor: PERMISSION_FLAGS[:read] | PERMISSION_FLAGS[:write] | PERMISSION_FLAGS[:edit], moderator: PERMISSION_FLAGS[:read] | PERMISSION_FLAGS[:write] | PERMISSION_FLAGS[:edit] | PERMISSION_FLAGS[:delete], admin: 0b11111111, # All permissions }.freeze
- PERMISSION_CATEGORIES =
Categorical masks for efficient broad queries
{ readable: 0b00000001, # Has basic access content_editor: 0b00001110, # Can modify content (append|write|edit) administrator: 0b11110000, # Has any admin powers privileged: 0b11111110, # Has beyond read-only owner: 0b11111111, # All permissions }.freeze
Class Method Summary collapse
-
.add_permissions(score, *permissions) ⇒ Float
Add permissions to existing score.
-
.categorize_scores(scores) ⇒ Hash
Efficient bulk categorization.
-
.category?(score, category) ⇒ Boolean
Check broad permission categories.
-
.current_score ⇒ Float
Get current timestamp as score (no permissions).
-
.decode_permission_flags(bits) ⇒ Array<Symbol>
Decode permission bits into array of permission symbols.
-
.decode_score(score) ⇒ Hash
Decode a Valkey/Redis score back into timestamp and permissions.
-
.encode_score(timestamp, permissions = 0) ⇒ Float
Encode a timestamp and permissions into a Valkey/Redis score.
-
.filter_by_category(scores, category) ⇒ Array<Float>
Filter collection by permission category.
-
.meets_category?(permission_bits, category) ⇒ Boolean
Check if permissions meet minimum category.
-
.permission?(score, *permissions) ⇒ Boolean
Check if score has specific permissions.
-
.permission_decode(score) ⇒ Hash
Decode score into permission information.
-
.permission_encode(timestamp, permission) ⇒ Float
Encode timestamp and permission (alias for encode_score).
-
.permission_level_value(permission) ⇒ Integer
Get permission bit flag value for a permission symbol.
-
.permission_range(min_permissions = [], max_permissions = nil) ⇒ Array<Float>
Create score range for permissions.
-
.permission_tier(score) ⇒ Symbol
Get permission tier for score.
-
.remove_permissions(score, *permissions) ⇒ Float
Remove permissions from existing score.
-
.score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Array
Create score range for db operations based on time bounds.
Instance Method Summary collapse
- #add_permissions(score, *permissions) ⇒ Object
- #current_score ⇒ Object
- #decode_score(score) ⇒ Object
-
#encode_score(timestamp, permissions = 0) ⇒ Object
Instance methods for classes that include this module.
- #permission?(score, *permissions) ⇒ Boolean
- #permission_decode(score) ⇒ Object
-
#permission_encode(timestamp, permission) ⇒ Object
Legacy method aliases for backward compatibility.
- #permission_range(min_permissions = [], max_permissions = nil) ⇒ Object
- #remove_permissions(score, *permissions) ⇒ Object
- #score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Object
Class Method Details
.add_permissions(score, *permissions) ⇒ Float
Add permissions to existing score
242 243 244 245 246 247 248 249 250 251 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 242 def (score, *) decoded = decode_score(score) current_bits = decoded[:permissions] new_bits = .reduce(current_bits) do |acc, perm| acc | (perm) end encode_score(decoded[:timestamp], new_bits) end |
.categorize_scores(scores) ⇒ Hash
Efficient bulk categorization
434 435 436 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 434 def categorize_scores(scores) scores.group_by { |score| (score) } end |
.category?(score, category) ⇒ Boolean
Check broad permission categories
Overlap test, not a classification: true when the score shares ANY bit with the category mask. The masks are not mutually exclusive, so one score answers true to several categories at once -- a moderator score (read|write|edit|delete) is true for all five. Use permission_tier when you want a single bucket instead.
Takes a PERMISSION_CATEGORIES key. Flags and roles are not categories; an unknown symbol returns false rather than raising, because there is no bit to misinterpret.
365 366 367 368 369 370 371 372 373 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 365 def category?(score, category) decoded = decode_score(score) = decoded[:permissions] mask = PERMISSION_CATEGORIES[category] return false unless mask .anybits?(mask) end |
.current_score ⇒ Float
Get current timestamp as score (no permissions)
298 299 300 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 298 def current_score encode_score(Familia.now, 0) end |
.decode_permission_flags(bits) ⇒ Array<Symbol>
Decode permission bits into array of permission symbols
346 347 348 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 346 def (bits) PERMISSION_FLAGS.select { |_name, flag| bits.anybits?(flag) }.keys end |
.decode_score(score) ⇒ Hash
Decode a Valkey/Redis score back into timestamp and permissions
202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 202 def decode_score(score) return { timestamp: 0, permissions: 0, permission_list: [] } unless score.is_a?(Numeric) time_part = score.to_i = ((score - time_part) * METADATA_PRECISION).round { timestamp: time_part, permissions: , permission_list: (), } end |
.encode_score(timestamp, permissions = 0) ⇒ Float
Encode a timestamp and permissions into a Valkey/Redis score
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 176 def encode_score(, = 0) time_part = .respond_to?(:to_i) ? .to_i : = case when Symbol PERMISSION_ROLES[] || () when Array # Support array of permission symbols .reduce(0) { |acc, p| acc | (p) } when Integer () else 0 end time_part + ( / METADATA_PRECISION) end |
.filter_by_category(scores, category) ⇒ Array<Float>
Filter collection by permission category
380 381 382 383 384 385 386 387 388 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 380 def filter_by_category(scores, category) mask = PERMISSION_CATEGORIES[category] return [] unless mask scores.select do |score| = ((score % 1) * METADATA_PRECISION).round .anybits?(mask) end end |
.meets_category?(permission_bits, category) ⇒ Boolean
Check if permissions meet minimum category
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 443 def meets_category?(, category) mask = PERMISSION_CATEGORIES[category] return false unless mask case category when :readable .positive? # Any permission implies read when :privileged > 1 # More than just read when :administrator .anybits?(PERMISSION_CATEGORIES[:administrator]) else .anybits?(mask) end end |
.permission?(score, *permissions) ⇒ Boolean
Check if score has specific permissions
224 225 226 227 228 229 230 231 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 224 def (score, *) decoded = decode_score(score) = decoded[:permissions] .all? do |perm| .anybits?((perm)) end end |
.permission_decode(score) ⇒ Hash
Decode score into permission information
150 151 152 153 154 155 156 157 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 150 def (score) decoded = decode_score(score) { timestamp: decoded[:timestamp], permissions: decoded[:permissions], permission_list: decoded[:permission_list], } end |
.permission_encode(timestamp, permission) ⇒ Float
Encode timestamp and permission (alias for encode_score)
142 143 144 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 142 def (, ) encode_score(, ) end |
.permission_level_value(permission) ⇒ Integer
Get permission bit flag value for a permission symbol
Accepts atomic PERMISSION_FLAGS only. Role symbols are rejected with a message naming the alternative -- see the "Flags versus roles" section in the module documentation for why they are not resolved here.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 119 def () flag = PERMISSION_FLAGS[] return flag if flag if PERMISSION_ROLES.key?() expansion = (PERMISSION_ROLES[]) raise ArgumentError, "#{.inspect} is a permission role, not a permission flag. " \ 'A role is accepted only by encode_score/permission_encode, and only in ' \ "the bare-symbol form -- encode_score(timestamp, #{.inspect}). " \ "Everywhere else, including the array form, pass atomic flags: #{expansion.map(&:inspect).join(', ')}." end raise ArgumentError, "Unknown permission: #{.inspect}. Valid flags: " \ "#{PERMISSION_FLAGS.keys.map(&:inspect).join(', ')}." end |
.permission_range(min_permissions = [], max_permissions = nil) ⇒ Array<Float>
Create score range for permissions
282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 282 def ( = [], = nil) min_bits = Array().reduce(0) { |acc, p| acc | (p) } max_bits = if Array().reduce(0) do |acc, p| acc | (p) end else 255 end [min_bits / METADATA_PRECISION, max_bits / METADATA_PRECISION] end |
.permission_tier(score) ⇒ Symbol
Get permission tier for score
Classification, not an overlap test: returns exactly one bucket, checking most-privileged first. The three masks it consults partition all eight bits (0b11110000 | 0b00001110 | 0b00000001 == 0xFF), so the highest set bit alone decides the answer.
Tier names are a FOURTH symbol namespace -- output only, never valid input to any method here. Two of them collide with PERMISSION_CATEGORIES keys while asking the opposite question, so a score can be true for category X and still tier as something else:
score = encode_score(t, PERMISSION_ROLES[:moderator]) # bits 45 category?(score, :content_editor) #=> true (overlaps the mask) permission_tier(score) #=> :administrator
The moderator lands in :administrator because :delete (bit 5) sits inside the administrator mask. Do not read a tier as "this user is a PERMISSION_ROLES[:administrator]" -- the role namespace does not even contain that name. Tier :viewer is the one exact correspondence: its mask arithmetic makes it reachable only at bits == 1, which is PERMISSION_ROLES[:viewer].
415 416 417 418 419 420 421 422 423 424 425 426 427 428 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 415 def (score) decoded = decode_score(score) bits = decoded[:permissions] if bits.anybits?(PERMISSION_CATEGORIES[:administrator]) :administrator elsif bits.anybits?(PERMISSION_CATEGORIES[:content_editor]) :content_editor elsif bits.anybits?(PERMISSION_CATEGORIES[:readable]) :viewer else :none end end |
.remove_permissions(score, *permissions) ⇒ Float
Remove permissions from existing score
262 263 264 265 266 267 268 269 270 271 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 262 def (score, *) decoded = decode_score(score) current_bits = decoded[:permissions] new_bits = .reduce(current_bits) do |acc, perm| acc & ~(perm) end encode_score(decoded[:timestamp], new_bits) end |
.score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Array
Create score range for db operations based on time bounds
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 316 def score_range(start_time = nil, end_time = nil, min_permissions: nil) min_bits = if Array().reduce(0) do |acc, p| acc | (p) end else 0 end min_score = if start_time encode_score(start_time, min_bits) elsif encode_score(0, min_bits) else '-inf' end max_score = if end_time encode_score(end_time, 255) # Use max valid permission bits else '+inf' end [min_score, max_score] end |
Instance Method Details
#add_permissions(score, *permissions) ⇒ Object
492 493 494 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 492 def (score, *) ScoreEncoding.(score, *) end |
#current_score ⇒ Object
504 505 506 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 504 def current_score ScoreEncoding.current_score end |
#decode_score(score) ⇒ Object
484 485 486 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 484 def decode_score(score) ScoreEncoding.decode_score(score) end |
#encode_score(timestamp, permissions = 0) ⇒ Object
Instance methods for classes that include this module
480 481 482 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 480 def encode_score(, = 0) ScoreEncoding.encode_score(, ) end |
#permission?(score, *permissions) ⇒ Boolean
488 489 490 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 488 def (score, *) ScoreEncoding.(score, *) end |
#permission_decode(score) ⇒ Object
517 518 519 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 517 def (score) ScoreEncoding.(score) end |
#permission_encode(timestamp, permission) ⇒ Object
Legacy method aliases for backward compatibility
513 514 515 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 513 def (, ) ScoreEncoding.(, ) end |
#permission_range(min_permissions = [], max_permissions = nil) ⇒ Object
500 501 502 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 500 def ( = [], = nil) ScoreEncoding.(, ) end |
#remove_permissions(score, *permissions) ⇒ Object
496 497 498 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 496 def (score, *) ScoreEncoding.(score, *) end |
#score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Object
508 509 510 |
# File 'lib/familia/features/relationships/score_encoding.rb', line 508 def score_range(start_time = nil, end_time = nil, min_permissions: nil) ScoreEncoding.score_range(start_time, end_time, min_permissions: ) end |