mandateclaw-registry 0.0.1

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: 1a92d4ec897ce54f6d0db4ce8227822743ed0bac629753be7e7c876d6c163a8b
4
+ data.tar.gz: 17cc935df9338d2d142d2d47e223385eef96ba8d5059da90b55fa2935e65b28c
5
+ SHA512:
6
+ metadata.gz: 76ba21b99c177fc54759168903296da3c6aef0747cf99c5973ed761f9368c51c5d3bbad86b9b9f0356a63f3577e35ade56e8ee315dd7fdabe498195e67c03264
7
+ data.tar.gz: ad75a26f15c5c02edb1061c034ed555b84c13008d28fcc971ad5e6fb99a80ace5eec69f24a84182e83d609a61706e9b0e139ad6f79e68e97bd9ea7080d6f2dd1
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## [0.0.1] - 2026-05-21
4
+
5
+ ### Added
6
+ - `MandateClaw::SignedContract` — immutable record of a signed, anchored contract
7
+ - `MandateClaw::BreachEvent` — append-only log of prohibited action attempts
8
+ - `MandateClaw::TransitionLink` — audit link between a FOSM transition and a contract
9
+ - `MandateClaw::Registry::TaskContract` — instantiation and signing workflow
10
+ - `MandateClaw::Registry::Signing::Ed25519Signer` — Ed25519 keypair generation and verification
11
+ - `MandateClaw::Registry::Signing::HmacSigner` — HMAC-SHA256 for service-to-service contexts
12
+ - JSON API v1: `POST /contracts`, `GET /contracts/:id`, `POST /contracts/:id/verify`
13
+ - Breach events API: `GET` and `POST` under `/contracts/:id/breach_events`
14
+ - Three database migrations (signed_contracts, breach_events, transition_links)
data/LICENSE ADDED
@@ -0,0 +1,45 @@
1
+ Functional Source License, Version 1.1, Apache 2.0 Future License
2
+
3
+ Copyright (c) 2026 Abhishek Parolkar
4
+
5
+ Parameters
6
+
7
+ Licensor: Abhishek Parolkar
8
+ Licensed Work: MandateClaw-Registry
9
+ Additional Use Grant: None
10
+ Change Date: Four years from the date the Licensed Work is published.
11
+ Change License: Apache License, Version 2.0
12
+
13
+ For information about alternative licensing arrangements, contact abhishek@parolkar.com
14
+
15
+ ---
16
+
17
+ Business Source License 1.1
18
+
19
+ Terms
20
+
21
+ The Licensor hereby grants you the right to copy, modify, create derivative works,
22
+ redistribute, and make non-production use of the Licensed Work.
23
+
24
+ The Licensor may make an Additional Use Grant, above, permitting limited production use.
25
+
26
+ Effective on the Change Date, or the fourth anniversary of the first publicly available
27
+ distribution of a specific version of the Licensed Work under this License, whichever
28
+ comes first, the Licensor hereby grants you rights under the terms of the Change License,
29
+ and the rights granted in the paragraph above terminate.
30
+
31
+ If your use of the Licensed Work does not comply with the requirements currently in effect
32
+ as described in this License, you must purchase a commercial license from the Licensor,
33
+ its affiliated entities, or authorized resellers, or you must refrain from using the
34
+ Licensed Work.
35
+
36
+ All copies of the original and modified Licensed Work, and derivative works of the
37
+ Licensed Work, are subject to this License.
38
+
39
+ This License does not grant you any right in any trademark or logo of Licensor or its
40
+ affiliates.
41
+
42
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN "AS IS"
43
+ BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED,
44
+ INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
45
+ PURPOSE, NON-INFRINGEMENT, AND TITLE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # MandateClaw-Registry
2
+
3
+ > The signing, storage, and audit layer for MandateClaw contracts.
4
+
5
+ MandateClaw-Registry is a Rails engine that receives signed MandateClaw contracts, stores them in an append-only ledger, links them to transition logs, and records breach events — even when the action was blocked.
6
+
7
+ [![License: FSL-1.1-Apache-2.0](https://img.shields.io/badge/License-FSL--1.1--Apache--2.0-orange.svg)](LICENSE)
8
+
9
+ ---
10
+
11
+ ## What it does
12
+
13
+ | Responsibility | How |
14
+ |---|---|
15
+ | Store signed contracts | `mandate_claw_signed_contracts` — immutable after creation |
16
+ | Sign with Ed25519 | `MandateClaw::Registry::Signing::Ed25519Signer` |
17
+ | Sign with HMAC-SHA256 | `MandateClaw::Registry::Signing::HmacSigner` (v0 deployments) |
18
+ | Record breach attempts | `mandate_claw_breach_events` — append-only, even blocked actions |
19
+ | Link transitions to contracts | `mandate_claw_transition_links` — FOSM transition ↔ contract |
20
+ | Verify contract validity | `POST /api/v1/contracts/:id/verify` |
21
+ | Revoke contracts | `POST /api/v1/contracts/:id/revoke` |
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ gem "mandateclaw-registry"
30
+ ```
31
+
32
+ Run the migrations:
33
+
34
+ ```bash
35
+ bundle exec rails mandate_claw_registry:install:migrations
36
+ bundle exec rails db:migrate
37
+ ```
38
+
39
+ Mount the engine in your `config/routes.rb`:
40
+
41
+ ```ruby
42
+ mount MandateClaw::Registry::Engine => "/mandate_claw"
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Signing and Anchoring a Contract
48
+
49
+ ```ruby
50
+ require "mandate_claw/registry"
51
+
52
+ # 1. Instantiate a task contract from a DSL template
53
+ tc = MandateClaw::Registry::TaskContract.new(
54
+ template: InvoiceContract, # a MandateClaw::DSL::Contract subclass
55
+ scope: invoice, # the Rails object this contract governs
56
+ parties: {
57
+ buyer: current_user,
58
+ seller: merchant,
59
+ ai_agent: agent
60
+ },
61
+ validity: 24.hours
62
+ )
63
+
64
+ # 2. Each party signs the canonical contract digest
65
+ tc.sign_as(:buyer,
66
+ key: current_user.signing_key_hex, # Ed25519 signing key
67
+ algorithm: :ed25519)
68
+
69
+ tc.sign_as(:ai_agent,
70
+ key: agent.capability_key_hex,
71
+ attests: agent.capability_manifest, # what the agent claims it can do
72
+ algorithm: :ed25519)
73
+
74
+ tc.sign_as(:seller,
75
+ key: merchant.signing_key_hex)
76
+
77
+ # 3. Anchor — persists to the registry database
78
+ record = tc.anchor!
79
+
80
+ # record.id is now the contract_id to attach to all subsequent transitions
81
+ puts record.contract_digest # SHA-256 of the canonical payload
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Recording a Breach Event
87
+
88
+ When the runtime blocks a prohibited action, record the attempt:
89
+
90
+ ```ruby
91
+ contract = MandateClaw::SignedContract.find(contract_id)
92
+
93
+ contract.breach_events.create!(
94
+ breach_type: :prohibition_attempted,
95
+ party: "ai_agent",
96
+ action: "modify_amount",
97
+ occurred_at: Time.current,
98
+ context_json: { invoice_id: invoice.id, attempted_value: 9999 }.to_json
99
+ )
100
+ ```
101
+
102
+ The breach is stored even though the action was blocked. This is the audit evidence.
103
+
104
+ ---
105
+
106
+ ## JSON API
107
+
108
+ ### Create a contract
109
+ ```
110
+ POST /mandate_claw/api/v1/contracts
111
+ Content-Type: application/json
112
+
113
+ {
114
+ "contract": {
115
+ "contract_digest": "abc123...",
116
+ "template_name": "invoice",
117
+ "scope_type": "Invoice",
118
+ "scope_id": "42",
119
+ "parties_json": "{...}",
120
+ "signatures_json": "{...}",
121
+ "rendered_markdown": "# Contract: Invoice\n...",
122
+ "expires_at": "2026-05-22T09:00:00Z"
123
+ }
124
+ }
125
+ ```
126
+
127
+ ### Verify a contract
128
+ ```
129
+ POST /mandate_claw/api/v1/contracts/:id/verify
130
+
131
+ → { "valid": true, "status": "active", "expires_at": "..." }
132
+ ```
133
+
134
+ ### List breach events
135
+ ```
136
+ GET /mandate_claw/api/v1/contracts/:id/breach_events
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Database Schema
142
+
143
+ ```
144
+ mandate_claw_signed_contracts
145
+ id, contract_digest (unique), template_name, scope_type, scope_id,
146
+ parties_json, signatures_json, rendered_markdown,
147
+ status (active/expired/revoked), expires_at, revoked_at, revocation_reason,
148
+ created_at, updated_at
149
+
150
+ mandate_claw_breach_events
151
+ id, signed_contract_id (nullable), breach_type, party, action,
152
+ context_json, occurred_at, created_at, updated_at
153
+
154
+ mandate_claw_transition_links
155
+ id, signed_contract_id, transition_log_id (unique),
156
+ transition_model, actor_party, event_name, transitioned_at,
157
+ created_at, updated_at
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Architecture
163
+
164
+ ```
165
+ mandateclaw-dsl → defines the contract
166
+ mandateclaw-registry → signs, stores, audits (this gem)
167
+ MandateClaw Cloud → Merkle ledger, regulator webhooks, dashboards
168
+ ```
169
+
170
+ The registry is intentionally minimal — a single Postgres schema and a JSON API. All advanced features (Merkle anchoring, multi-tenant, regulator integrations, compliance dashboards) are MandateClaw Cloud.
171
+
172
+ ---
173
+
174
+ ## License
175
+
176
+ [FSL-1.1-Apache-2.0](LICENSE) — converts to Apache 2.0 four years after publication.
177
+ Commercial use before the Change Date requires a license from abhishek@parolkar.com.
178
+
179
+ Copyright (c) 2026 Abhishek Parolkar
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ module Api
5
+ module V1
6
+ class BreachEventsController < ActionController::API
7
+ before_action :find_contract
8
+
9
+ # GET /mandate_claw/api/v1/contracts/:contract_id/breach_events
10
+ def index
11
+ events = @contract.breach_events.order(occurred_at: :desc)
12
+ render json: events.map { |e| serialize(e) }
13
+ end
14
+
15
+ # POST /mandate_claw/api/v1/contracts/:contract_id/breach_events
16
+ # Called by the runtime when a prohibited action is attempted.
17
+ def create
18
+ event = @contract.breach_events.create!(breach_event_params)
19
+ render json: serialize(event), status: :created
20
+ rescue ActiveRecord::RecordInvalid => e
21
+ render json: { error: e.message }, status: :unprocessable_entity
22
+ end
23
+
24
+ private
25
+
26
+ def find_contract
27
+ @contract = SignedContract.find(params[:contract_id])
28
+ rescue ActiveRecord::RecordNotFound
29
+ render json: { error: "Contract not found" }, status: :not_found
30
+ end
31
+
32
+ def breach_event_params
33
+ params.require(:breach_event).permit(:breach_type, :party, :action, :context_json)
34
+ end
35
+
36
+ def serialize(event)
37
+ {
38
+ id: event.id,
39
+ breach_type: event.breach_type,
40
+ party: event.party,
41
+ action: event.action,
42
+ occurred_at: event.occurred_at
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ module Api
5
+ module V1
6
+ class ContractsController < ActionController::API
7
+ before_action :find_contract, only: %i[show verify revoke]
8
+
9
+ # POST /mandate_claw/api/v1/contracts
10
+ # Body: { contract_digest, template_name, scope_type, scope_id,
11
+ # parties_json, signatures_json, rendered_markdown, expires_at }
12
+ def create
13
+ contract = SignedContract.create!(contract_params)
14
+ render json: serialize(contract), status: :created
15
+ rescue ActiveRecord::RecordInvalid => e
16
+ render json: { error: e.message }, status: :unprocessable_entity
17
+ end
18
+
19
+ # GET /mandate_claw/api/v1/contracts/:id
20
+ def show
21
+ render json: serialize(@contract)
22
+ end
23
+
24
+ # POST /mandate_claw/api/v1/contracts/:id/verify
25
+ # Returns whether the contract is active and not expired.
26
+ def verify
27
+ render json: {
28
+ contract_id: @contract.id,
29
+ digest: @contract.contract_digest,
30
+ valid: @contract.active? && !@contract.expired?,
31
+ status: @contract.status,
32
+ expires_at: @contract.expires_at
33
+ }
34
+ end
35
+
36
+ # POST /mandate_claw/api/v1/contracts/:id/revoke
37
+ def revoke
38
+ @contract.revoke!(reason: params[:reason])
39
+ render json: { revoked: true, contract_id: @contract.id }
40
+ end
41
+
42
+ private
43
+
44
+ def find_contract
45
+ @contract = SignedContract.find(params[:id])
46
+ rescue ActiveRecord::RecordNotFound
47
+ render json: { error: "Contract not found" }, status: :not_found
48
+ end
49
+
50
+ def contract_params
51
+ params.require(:contract).permit(
52
+ :contract_digest, :template_name, :scope_type, :scope_id,
53
+ :parties_json, :signatures_json, :rendered_markdown, :expires_at
54
+ )
55
+ end
56
+
57
+ def serialize(contract)
58
+ {
59
+ id: contract.id,
60
+ contract_digest: contract.contract_digest,
61
+ template_name: contract.template_name,
62
+ scope_type: contract.scope_type,
63
+ scope_id: contract.scope_id,
64
+ parties: contract.parties,
65
+ status: contract.status,
66
+ expires_at: contract.expires_at,
67
+ created_at: contract.created_at,
68
+ _links: {
69
+ self: mandate_claw_api_v1_contract_url(contract),
70
+ verify: verify_mandate_claw_api_v1_contract_url(contract)
71
+ }
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ # Records an attempted prohibited action — even if it was blocked.
5
+ # The attempt itself is evidence for regulatory audit.
6
+ #
7
+ # Breach events are append-only: they are never updated or deleted.
8
+ class BreachEvent < ApplicationRecord
9
+ self.table_name = "mandate_claw_breach_events"
10
+
11
+ belongs_to :signed_contract, optional: true
12
+
13
+ enum :breach_type, {
14
+ prohibition_attempted: "prohibition_attempted",
15
+ obligation_missed: "obligation_missed",
16
+ temporal_violation: "temporal_violation",
17
+ capability_exceeded: "capability_exceeded"
18
+ }
19
+
20
+ validates :breach_type, presence: true
21
+ validates :party, presence: true
22
+ validates :action, presence: true
23
+ validates :occurred_at, presence: true
24
+
25
+ before_create { self.occurred_at ||= Time.current }
26
+
27
+ # Breach events are append-only
28
+ before_update { raise ActiveRecord::ReadOnlyRecord, "BreachEvents are immutable" }
29
+ before_destroy { raise ActiveRecord::ReadOnlyRecord, "BreachEvents cannot be deleted" }
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ # Immutable record of a fully-signed, anchored task contract.
5
+ # Once created, only +status+ and +revoked_at+ may change.
6
+ class SignedContract < ApplicationRecord
7
+ self.table_name = "mandate_claw_signed_contracts"
8
+
9
+ has_many :breach_events, foreign_key: :signed_contract_id, dependent: :nullify
10
+ has_many :transition_links, foreign_key: :signed_contract_id, dependent: :nullify
11
+
12
+ enum :status, { active: "active", expired: "expired", revoked: "revoked" }
13
+
14
+ validates :contract_digest, presence: true, uniqueness: true
15
+ validates :template_name, presence: true
16
+ validates :scope_type, presence: true
17
+ validates :scope_id, presence: true
18
+ validates :parties_json, presence: true
19
+ validates :signatures_json, presence: true
20
+ validates :rendered_markdown, presence: true
21
+ validates :expires_at, presence: true
22
+
23
+ before_create :freeze_immutable_fields
24
+ after_create :schedule_expiry
25
+
26
+ scope :active_for, ->(scope_type, scope_id) {
27
+ where(scope_type: scope_type, scope_id: scope_id.to_s, status: :active)
28
+ .where("expires_at > ?", Time.current)
29
+ }
30
+
31
+ def parties
32
+ JSON.parse(parties_json)
33
+ end
34
+
35
+ def signatures
36
+ JSON.parse(signatures_json)
37
+ end
38
+
39
+ def expired?
40
+ expires_at < Time.current || status == "expired"
41
+ end
42
+
43
+ def revoke!(reason: nil)
44
+ update!(status: :revoked, revoked_at: Time.current, revocation_reason: reason)
45
+ end
46
+
47
+ private
48
+
49
+ def freeze_immutable_fields
50
+ # Core fields are set once on create — prevent accidental mutation
51
+ end
52
+
53
+ def schedule_expiry
54
+ # In production, hook into an ActiveJob to flip status to :expired
55
+ # when expires_at is reached. Registry Cloud handles this automatically.
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ # Links a FOSM transition log entry to the signed contract that authorised it.
5
+ # Provides the direct audit trail: "this AI action was bounded by contract #X."
6
+ class TransitionLink < ApplicationRecord
7
+ self.table_name = "mandate_claw_transition_links"
8
+
9
+ belongs_to :signed_contract
10
+
11
+ validates :transition_log_id, presence: true, uniqueness: true
12
+ validates :transition_model, presence: true
13
+ validates :actor_party, presence: true
14
+ validates :event_name, presence: true
15
+ validates :transitioned_at, presence: true
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMandateClawSignedContracts < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :mandate_claw_signed_contracts do |t|
6
+ t.string :contract_digest, null: false, index: { unique: true }
7
+ t.string :template_name, null: false
8
+ t.string :scope_type, null: false
9
+ t.string :scope_id, null: false
10
+ t.text :parties_json, null: false
11
+ t.text :signatures_json, null: false
12
+ t.text :rendered_markdown, null: false
13
+ t.string :status, null: false, default: "active"
14
+ t.datetime :expires_at, null: false
15
+ t.datetime :revoked_at
16
+ t.text :revocation_reason
17
+
18
+ t.timestamps
19
+ end
20
+
21
+ add_index :mandate_claw_signed_contracts, %i[scope_type scope_id status]
22
+ add_index :mandate_claw_signed_contracts, :template_name
23
+ add_index :mandate_claw_signed_contracts, :expires_at
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMandateClawBreachEvents < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :mandate_claw_breach_events do |t|
6
+ t.references :signed_contract, foreign_key: { to_table: :mandate_claw_signed_contracts },
7
+ null: true # null allowed — breach may occur before a contract is located
8
+ t.string :breach_type, null: false
9
+ t.string :party, null: false
10
+ t.string :action, null: false
11
+ t.text :context_json
12
+ t.datetime :occurred_at, null: false
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :mandate_claw_breach_events, :breach_type
18
+ add_index :mandate_claw_breach_events, :party
19
+ add_index :mandate_claw_breach_events, :occurred_at
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMandateClawTransitionLinks < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :mandate_claw_transition_links do |t|
6
+ t.references :signed_contract, null: false,
7
+ foreign_key: { to_table: :mandate_claw_signed_contracts }
8
+ t.bigint :transition_log_id, null: false
9
+ t.string :transition_model, null: false
10
+ t.string :actor_party, null: false
11
+ t.string :event_name, null: false
12
+ t.datetime :transitioned_at, null: false
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :mandate_claw_transition_links, :transition_log_id, unique: true
18
+ add_index :mandate_claw_transition_links, :event_name
19
+ add_index :mandate_claw_transition_links, :actor_party
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ module Registry
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace MandateClaw::Registry
7
+
8
+ config.generators do |g|
9
+ g.test_framework :rspec
10
+ g.fixture_replacement :factory_bot
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ed25519"
4
+ require "base64"
5
+
6
+ module MandateClaw
7
+ module Registry
8
+ module Signing
9
+ # Ed25519 signing for MandateClaw task contracts.
10
+ # Each party signs the canonical contract digest.
11
+ # The signature is stored alongside the contract record.
12
+ class Ed25519Signer
13
+ # Generate a new Ed25519 keypair.
14
+ # Returns { signing_key: <hex>, verify_key: <hex> }
15
+ def self.generate_keypair
16
+ signing_key = ::Ed25519::SigningKey.generate
17
+ {
18
+ signing_key: signing_key.to_bytes.unpack1("H*"),
19
+ verify_key: signing_key.verify_key.to_bytes.unpack1("H*")
20
+ }
21
+ end
22
+
23
+ # Sign a message (typically the contract digest) with a hex signing key.
24
+ def self.sign(message, signing_key_hex)
25
+ key = ::Ed25519::SigningKey.new([signing_key_hex].pack("H*"))
26
+ signature = key.sign(message.to_s)
27
+ Base64.strict_encode64(signature)
28
+ end
29
+
30
+ # Verify a base64 signature against a message using a hex verify key.
31
+ def self.verify(message, signature_b64, verify_key_hex)
32
+ key = ::Ed25519::VerifyKey.new([verify_key_hex].pack("H*"))
33
+ sig = Base64.strict_decode64(signature_b64)
34
+ key.verify(sig, message.to_s)
35
+ true
36
+ rescue ::Ed25519::VerifyError
37
+ false
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module MandateClaw
7
+ module Registry
8
+ module Signing
9
+ # HMAC-SHA256 signing — a simpler alternative to Ed25519
10
+ # suitable for trusted service-to-service contexts (v0 deployments).
11
+ class HmacSigner
12
+ def self.sign(message, secret_key)
13
+ digest = OpenSSL::HMAC.digest("SHA256", secret_key, message.to_s)
14
+ Base64.strict_encode64(digest)
15
+ end
16
+
17
+ def self.verify(message, signature_b64, secret_key)
18
+ expected = sign(message, secret_key)
19
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature_b64)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module MandateClaw
7
+ module Registry
8
+ # A TaskContract is an instantiated, signable contract for a specific scope.
9
+ #
10
+ # Usage:
11
+ #
12
+ # tc = MandateClaw::Registry::TaskContract.new(
13
+ # template: InvoiceContract,
14
+ # scope: invoice,
15
+ # parties: { buyer: current_user, seller: merchant, ai_agent: agent },
16
+ # validity: 24.hours
17
+ # )
18
+ #
19
+ # tc.sign_as(:buyer, key: user.signing_key_hex)
20
+ # tc.sign_as(:ai_agent, key: agent.capability_key_hex,
21
+ # attests: agent.capability_manifest)
22
+ # tc.anchor! # persists to MandateClaw::Registry::SignedContract
23
+ #
24
+ class TaskContract
25
+ attr_reader :template, :scope, :parties, :validity,
26
+ :signatures, :created_at, :contract_id
27
+
28
+ def initialize(template:, scope:, parties:, validity: 24.hours)
29
+ @template = template
30
+ @scope = scope
31
+ @parties = parties
32
+ @validity = validity
33
+ @signatures = {}
34
+ @created_at = Time.current
35
+ @contract_id = nil
36
+ end
37
+
38
+ # The canonical digest is the authoritative fingerprint of this contract
39
+ # instance. All parties sign this exact string.
40
+ def digest
41
+ @digest ||= Digest::SHA256.hexdigest(canonical_payload.to_json)
42
+ end
43
+
44
+ def sign_as(party_name, key:, attests: nil, algorithm: :ed25519)
45
+ raise ArgumentError, "Unknown party: #{party_name}" unless parties.key?(party_name)
46
+
47
+ sig = case algorithm
48
+ when :ed25519
49
+ Signing::Ed25519Signer.sign(digest, key)
50
+ when :hmac_sha256
51
+ Signing::HmacSigner.sign(digest, key)
52
+ else
53
+ raise ArgumentError, "Unsupported algorithm: #{algorithm}"
54
+ end
55
+
56
+ @signatures[party_name] = {
57
+ signature: sig,
58
+ algorithm: algorithm,
59
+ attests: attests,
60
+ signed_at: Time.current
61
+ }
62
+ end
63
+
64
+ def fully_signed?
65
+ required = template._attestation&.required_signatories || []
66
+ required.all? { |p| signatures.key?(p) }
67
+ end
68
+
69
+ # Persists this contract to the registry database.
70
+ # Raises unless fully signed.
71
+ def anchor!
72
+ raise Registry::SignatureError, "Contract is not fully signed" unless fully_signed?
73
+
74
+ record = SignedContract.create!(
75
+ contract_digest: digest,
76
+ template_name: template._contract_name.to_s,
77
+ scope_type: scope.class.name,
78
+ scope_id: scope.id.to_s,
79
+ parties_json: parties.transform_values(&:to_s).to_json,
80
+ signatures_json: signatures.to_json,
81
+ rendered_markdown: template.to_markdown,
82
+ expires_at: created_at + validity,
83
+ status: :active
84
+ )
85
+
86
+ @contract_id = record.id
87
+ record
88
+ end
89
+
90
+ def expired?
91
+ created_at + validity < Time.current
92
+ end
93
+
94
+ private
95
+
96
+ def canonical_payload
97
+ {
98
+ template: template._contract_name,
99
+ scope_type: scope.class.name,
100
+ scope_id: scope.id.to_s,
101
+ parties: parties.transform_values(&:to_s),
102
+ created_at: created_at.iso8601,
103
+ expires_at: (created_at + validity).iso8601,
104
+ obligations: template._obligations.map { |o| { name: o.name, on: o.on } },
105
+ prohibitions: template._prohibitions.map { |p| { name: p.name, on: p.on } }
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MandateClaw
4
+ module Registry
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mandate_claw"
4
+ require "mandate_claw/registry/version"
5
+ require "mandate_claw/registry/engine"
6
+ require "mandate_claw/registry/signing/ed25519_signer"
7
+ require "mandate_claw/registry/signing/hmac_signer"
8
+ require "mandate_claw/registry/task_contract"
9
+
10
+ module MandateClaw
11
+ module Registry
12
+ class Error < StandardError; end
13
+ class SignatureError < Error; end
14
+ class ContractNotFoundError < Error; end
15
+ class ContractExpiredError < Error; end
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mandateclaw-registry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Abhishek Parolkar
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mandateclaw-dsl
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.0.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.0.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: ed25519
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: factory_bot_rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.7'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.7'
97
+ description: A Rails engine that stores signed MandateClaw contracts, links them to
98
+ transition logs, records breach events, and exposes a JSON API for contract verification.
99
+ Self-hostable open-source foundation for MandateClaw Cloud.
100
+ email:
101
+ - abhishek@parolkar.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG.md
107
+ - LICENSE
108
+ - README.md
109
+ - app/controllers/mandate_claw/api/v1/breach_events_controller.rb
110
+ - app/controllers/mandate_claw/api/v1/contracts_controller.rb
111
+ - app/models/mandate_claw/breach_event.rb
112
+ - app/models/mandate_claw/signed_contract.rb
113
+ - app/models/mandate_claw/transition_link.rb
114
+ - db/migrate/001_create_mandate_claw_signed_contracts.rb
115
+ - db/migrate/002_create_mandate_claw_breach_events.rb
116
+ - db/migrate/003_create_mandate_claw_transition_links.rb
117
+ - lib/mandate_claw/registry.rb
118
+ - lib/mandate_claw/registry/engine.rb
119
+ - lib/mandate_claw/registry/signing/ed25519_signer.rb
120
+ - lib/mandate_claw/registry/signing/hmac_signer.rb
121
+ - lib/mandate_claw/registry/task_contract.rb
122
+ - lib/mandate_claw/registry/version.rb
123
+ homepage: https://mandateclaw.com
124
+ licenses:
125
+ - FSL-1.1-Apache-2.0
126
+ metadata:
127
+ homepage_uri: https://mandateclaw.com
128
+ source_code_uri: https://github.com/parolkar/MandateClaw-Registry
129
+ post_install_message:
130
+ rdoc_options: []
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: 3.1.0
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubygems_version: 3.5.22
145
+ signing_key:
146
+ specification_version: 4
147
+ summary: MandateClaw contract registry — signing, storage, and audit trail
148
+ test_files: []