rbbb 0.1.0.pre.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b73b49d61e104b20d7f14c8894477fd34079184c053d115d25cc8fca098a16b5
4
+ data.tar.gz: 206540fe6a06c4cdec54b076eab100e8fe712e103ca3f6ec4bb7acc3b185eb49
5
+ SHA512:
6
+ metadata.gz: 8424b68bfbf71aeeb87cf4cffa988719db1a78719c38e0d1d44b2e1979e237ae0edfca3b80638c7bae536fc83170d1ed0c1e893ce247eeffaa08ba9b52404b99
7
+ data.tar.gz: 6e46a05de2032c7ef7f745b917bffd29f23034cc3b3e1f09b66f6c3019f9709aa53392c24f6e98e163c04c502aacef32dab37502510b1e123e24cbae3570a7f2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 R Triple B contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # RBBB Ruby reference engine
2
+
3
+ This directory contains the pure-Ruby reference implementation of the RBBB
4
+ specification.
5
+
6
+ > [!WARNING]
7
+ > The gem is an early RFC 0001 implementation. It is not production ready and
8
+ > must not be used in a live auction.
9
+
10
+ The engine will remain independent of Rails, databases, HTTP, jobs, and
11
+ authentication. Its public decision boundary is:
12
+
13
+ ```ruby
14
+ decision = engine.decide(current_state, command)
15
+ new_state = engine.apply(current_state, decision.events)
16
+ ```
17
+
18
+ The current implementation covers exact minor-unit money, configurable
19
+ increment tiers, opening bids, proxy competition, earlier-equal priority,
20
+ proxy clipping, challenger minimums, private leader maximum increases, and
21
+ confidential reserve pricing and status. It also enforces authoritative closing
22
+ times, configurable per-unit soft-close extensions, and bidder withdrawal of
23
+ unexecuted proxy authority. Authorized operator bid voiding preserves the
24
+ original bid, deterministically recomputes standing and executed amounts, and
25
+ emits host-facing notification intent. Audited operator commands may also
26
+ change closing time and reserve under the RFC's pre-bid and post-bid
27
+ constraints without unwinding already executed amounts. Explicit ordered
28
+ closing produces `sold`, `no_sale`, or `no_bid` terminal state. Persistence,
29
+ networking, service-level idempotency, and authentication are not implemented.
30
+
31
+ The pure engine records `operator_id`; it does not authenticate or authorize
32
+ that identity. A host must authorize the operator before submitting any
33
+ operator command and must deliver or retry any requested notifications.
34
+
35
+ ```ruby
36
+ configuration = RBBB::Configuration.new(
37
+ currency: "USD",
38
+ opening_minor_units: 10_000,
39
+ increments: [{from_minor_units: 0, amount_minor_units: 1_000}],
40
+ opens_at: "2026-09-01T12:00:00Z",
41
+ closes_at: "2026-09-01T13:00:00Z",
42
+ extension: {trigger_window_seconds: 300, duration_seconds: 300}
43
+ )
44
+ engine = RBBB::Engine.new(configuration)
45
+ state = engine.initial_state
46
+
47
+ decision = engine.decide(state, {
48
+ command_id: "command-1",
49
+ type: "place_bid",
50
+ bidder_id: "bidder-a",
51
+ maximum_minor_units: 50_000,
52
+ effective_at: "2026-09-01T12:10:00Z"
53
+ })
54
+ state = engine.apply(state, decision.events) if decision.accepted?
55
+ ```
56
+
57
+ ### Rejected commands
58
+
59
+ A command that cannot be applied yields `decision.rejected?` and no events.
60
+ `decision.rejection` is a frozen hash holding `command_id`, `reason` (one of
61
+ the enumerated codes in `specification/rejections/rejection.schema.json`), and
62
+ `status: "rejected"`, and at most one reason-specific field:
63
+ `executed_floor_minor_units`, present only with
64
+ `maximum_below_executed_amount`. There is no open-ended details object, and
65
+ the rejection is already the complete document the schema describes; a
66
+ service adapter must transmit it without adding or removing fields. The
67
+ normative rules live in
68
+ `specification/contract.md` under "Service envelope and core inputs" and the
69
+ rejection paragraph under "Events and visibility". A host must return
70
+ `executed_floor_minor_units` only to the bidder who issued the rejected
71
+ command and must never place it in a public projection.
72
+
73
+ ```ruby
74
+ decision = engine.decide(state, {
75
+ command_id: "command-2",
76
+ type: "reduce_maximum",
77
+ bidder_id: "bidder-a",
78
+ maximum_minor_units: 5_000,
79
+ effective_at: "2026-09-01T12:11:00Z"
80
+ })
81
+ if decision.rejected?
82
+ decision.rejection
83
+ # => {"command_id" => "command-2",
84
+ # "status" => "rejected",
85
+ # "reason" => "maximum_below_executed_amount",
86
+ # "executed_floor_minor_units" => 10_000}
87
+ end
88
+ ```
89
+
90
+ ## Snapshots, checkpoints, and the public view
91
+
92
+ `State#to_h` is the full privileged aggregate snapshot. With the host's
93
+ `auction_id`, `bidding_unit_id`, and `currency` added it satisfies
94
+ `specification/state/aggregate.schema.json`, and `RBBB::State.from_h`
95
+ rebuilds a validated state from it. It contains bidder identities, maxima,
96
+ the reserve amount, and audit history, so it must never be published.
97
+
98
+ Every privileged state-transition event already carries that snapshot, so a
99
+ host can checkpoint from the latest transition instead of replaying the
100
+ stream from version 0:
101
+
102
+ ```ruby
103
+ transition = decision.events.find { |event| event.privileged? && event.type == "maximum_accepted" }
104
+ restored = engine.restore(transition) # or RBBB::State.from_transition(configuration, transition)
105
+ restored.to_h == state.to_h # => true
106
+ ```
107
+
108
+ `State#public_view` is the public query projection. With the same three host
109
+ fields added it satisfies `specification/state/bidding-unit.schema.json`. It
110
+ never carries a bidder, leader, or winner identity, a maximum, the reserve
111
+ amount, or audit history; serve it rather than hand-rolling a projection
112
+ from the aggregate.
113
+
114
+ ## Install the evaluation gem
115
+
116
+ Version `0.1.0.pre.3` is an experimental evaluation package. It has no runtime
117
+ dependencies and supports Ruby 3.2 and newer. Install the exact prerelease from
118
+ RubyGems.org:
119
+
120
+ ```sh
121
+ gem install rbbb --version 0.1.0.pre.3
122
+ ruby -rrbbb -e 'puts [RBBB::VERSION, RBBB::SPECIFICATION_VERSION, RBBB::RELEASE_STATUS].join(" ")'
123
+ ```
124
+
125
+ To build the same version from a reviewed checkout:
126
+
127
+ ```sh
128
+ cd ruby/engine
129
+ bundle exec rake package:verify
130
+ gem build rbbb.gemspec
131
+ gem install ./rbbb-0.1.0.pre.3.gem
132
+ ruby -rrbbb -e 'puts [RBBB::VERSION, RBBB::SPECIFICATION_VERSION, RBBB::RELEASE_STATUS].join(" ")'
133
+ ```
134
+
135
+ For local application evaluation with Bundler:
136
+
137
+ ```ruby
138
+ gem "rbbb", path: "/path/to/rbbb/ruby/engine"
139
+ ```
140
+
141
+ The [matching GitHub release](https://github.com/willtmc/rbbb/releases) carries
142
+ the same `.gem` artifact and its SHA-256 checksum.
143
+
144
+ The installed package exposes its implementation version, claimed
145
+ specification version, and release status as `RBBB::VERSION`,
146
+ `RBBB::SPECIFICATION_VERSION`, and `RBBB::RELEASE_STATUS`. A package version is
147
+ not, by itself, a production-readiness or compatibility claim.
148
+
149
+ ## Development
150
+
151
+ ```sh
152
+ bundle install
153
+ bundle exec rake
154
+ ```
155
+
156
+ The default task includes `package:verify`, which builds the gem in a temporary
157
+ directory, checks the exact file allowlist, installs it into an isolated gem
158
+ home, and runs a bid through the installed artifact. Run `bundle exec rake
159
+ package:verify` when only that check is needed.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBBB
4
+ # Immutable bidding-unit configuration consumed by the pure engine.
5
+ #
6
+ # RFC 0001 defines only timed bidding units, so +opens_at+ and +closes_at+
7
+ # are required alongside currency, opening amount, and increments.
8
+ class Configuration
9
+ attr_reader :currency, :opening_minor_units, :reserve_minor_units,
10
+ :increment_schedule, :opens_at, :closes_at, :extension
11
+
12
+ def self.from_h(attributes)
13
+ values = attributes.transform_keys(&:to_s)
14
+ new(
15
+ currency: values.fetch("currency"),
16
+ opening_minor_units: values.fetch("opening_minor_units"),
17
+ reserve_minor_units: values["reserve_minor_units"],
18
+ increments: values.fetch("increments"),
19
+ opens_at: values.fetch("opens_at"),
20
+ closes_at: values.fetch("closes_at"),
21
+ extension: values["extension"]
22
+ )
23
+ rescue KeyError => e
24
+ raise InvalidConfiguration, "configuration is missing #{e.key}"
25
+ end
26
+
27
+ def initialize(currency:, opening_minor_units:, increments:, reserve_minor_units: nil,
28
+ opens_at: nil, closes_at: nil, extension: nil)
29
+ Money.new(currency: currency, minor_units: opening_minor_units)
30
+ unless Money.amount?(opening_minor_units)
31
+ raise InvalidConfiguration, "opening amount must be a non-negative integer no greater than #{Money::MAX_MINOR_UNITS}"
32
+ end
33
+ if !reserve_minor_units.nil? && !Money.amount?(reserve_minor_units)
34
+ raise InvalidConfiguration, "reserve must be a non-negative integer no greater than #{Money::MAX_MINOR_UNITS}"
35
+ end
36
+
37
+ @currency = currency.freeze
38
+ @opening_minor_units = opening_minor_units
39
+ @reserve_minor_units = reserve_minor_units
40
+ @increment_schedule = IncrementSchedule.new(increments)
41
+ @opens_at = parse_timestamp(opens_at, "opens_at")
42
+ @closes_at = parse_timestamp(closes_at, "closes_at")
43
+ if @opens_at >= @closes_at
44
+ raise InvalidConfiguration, "opens_at must be earlier than closes_at"
45
+ end
46
+ @extension = normalize_extension(extension)
47
+ freeze
48
+ rescue InvalidMoney => e
49
+ raise InvalidConfiguration, e.message
50
+ end
51
+
52
+ def increment_for(minor_units)
53
+ increment_schedule.increment_for(minor_units)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_timestamp(value, field)
59
+ raise InvalidConfiguration, "configuration is missing #{field}" if value.nil?
60
+
61
+ Timestamp.parse(value)
62
+ rescue ArgumentError
63
+ raise InvalidConfiguration, "#{field} must be a valid ISO 8601 timestamp"
64
+ end
65
+
66
+ def normalize_extension(extension)
67
+ return nil if extension.nil?
68
+ unless extension.respond_to?(:transform_keys)
69
+ raise InvalidConfiguration, "extension must be an object"
70
+ end
71
+
72
+ values = extension.transform_keys(&:to_s)
73
+ trigger = values["trigger_window_seconds"]
74
+ duration = values["duration_seconds"]
75
+ # Seconds share the interoperable integer bound so closing-time
76
+ # arithmetic (command time plus duration) stays representable in every
77
+ # implementation and inside the RFC 3339 four-digit-year range.
78
+ unless trigger.is_a?(Integer) && trigger >= 0 && trigger <= MAX_SAFE_INTEGER
79
+ raise InvalidConfiguration,
80
+ "extension trigger window must be a non-negative integer no greater than #{MAX_SAFE_INTEGER}"
81
+ end
82
+ unless duration.is_a?(Integer) && duration.positive? && duration <= MAX_SAFE_INTEGER
83
+ raise InvalidConfiguration,
84
+ "extension duration must be a positive integer no greater than #{MAX_SAFE_INTEGER}"
85
+ end
86
+
87
+ {
88
+ "trigger_window_seconds" => trigger,
89
+ "duration_seconds" => duration
90
+ }.freeze
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBBB
4
+ # Accepted events or one stable rejection from evaluating a command.
5
+ class Decision
6
+ # The constant status every rejection carries. The pure engine emits the
7
+ # complete rejection document defined by
8
+ # specification/rejections/rejection.schema.json; a service adapter must
9
+ # transmit it without adding or removing fields.
10
+ REJECTED_STATUS = "rejected"
11
+
12
+ # Rejection fields beyond command_id, status, and reason, keyed by the only
13
+ # reason that may carry them. Mirrors the rejection schema; there is
14
+ # deliberately no open-ended details object.
15
+ REASON_SPECIFIC_FIELDS = {
16
+ "maximum_below_executed_amount" => %w[executed_floor_minor_units].freeze
17
+ }.freeze
18
+
19
+ attr_reader :events, :rejection
20
+
21
+ def self.accepted(events)
22
+ new(events: events, rejection: nil)
23
+ end
24
+
25
+ def self.rejected(command_id:, reason:, executed_floor_minor_units: nil)
26
+ rejection = {"command_id" => command_id, "status" => REJECTED_STATUS, "reason" => reason}
27
+ allowed = REASON_SPECIFIC_FIELDS.fetch(reason, [])
28
+
29
+ if allowed.include?("executed_floor_minor_units")
30
+ unless executed_floor_minor_units.is_a?(Integer) && executed_floor_minor_units >= 0
31
+ raise ArgumentError, "#{reason} requires a non-negative integer executed_floor_minor_units"
32
+ end
33
+
34
+ rejection["executed_floor_minor_units"] = executed_floor_minor_units
35
+ elsif !executed_floor_minor_units.nil?
36
+ raise ArgumentError, "executed_floor_minor_units is only reported for maximum_below_executed_amount"
37
+ end
38
+
39
+ new(events: [], rejection: rejection.freeze)
40
+ end
41
+
42
+ def initialize(events:, rejection:)
43
+ @events = events.freeze
44
+ @rejection = rejection
45
+ freeze
46
+ end
47
+
48
+ def accepted?
49
+ rejection.nil?
50
+ end
51
+
52
+ def rejected?
53
+ !accepted?
54
+ end
55
+ end
56
+ end