belt-policies 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: e56c61638db691eab171bb956d2bd4ed1fde23b74f828cf92d14088f437b2a7c
4
+ data.tar.gz: 4d1fc3ba1d3e631651d91a8dad51ef1b8d5561d064f2462327aa2d07c7e86def
5
+ SHA512:
6
+ metadata.gz: db3d2b647489d6c73b6f91ae1959b22ac80b93729c1270e20ed53178918dca16affe944686abbb20c075fe0ff53b00d8c79542f896644a48ebef2c83d5c35439
7
+ data.tar.gz: 663a7facbf91bbf6e81d9cbd11616a9eb0d350e6f767cf6e0eeeb1ce90be7ca9ea5e4ba733cdce3ba36734884d8c8cc9b1b9274631d234e4ae04c367021455f2
data/AGENTS.md ADDED
@@ -0,0 +1,294 @@
1
+ # AGENTS.md — belt-policies
2
+
3
+ For agents (and humans) working **on this gem**. To *use* it in a Belt app,
4
+ read [README.md](README.md).
5
+
6
+ ## Not legal advice. Keep it that way
7
+
8
+ This gem must never present itself as providing legal content. Templates are
9
+ outlines: headings plus `TODO:` prompts naming topics. Don't add model
10
+ policy text, "standard clauses", or claims about what a law requires. Every
11
+ user-facing entry point (generator output, `--help`, README, generated files)
12
+ carries the `Belt::Policies::NOT_LEGAL_ADVICE` notice. Keep it there.
13
+
14
+ ## Layout
15
+
16
+ ```
17
+ lib/
18
+ ├── belt-policies.rb # require shim
19
+ ├── belt/policies.rb # public API: current, accept!, status_for, publish!, …
20
+ ├── belt/policies/
21
+ │ ├── document.rb # one policies/<slug>.md (front matter + body)
22
+ │ ├── catalog.rb # built-in kinds (titles, sections, prompts)
23
+ │ ├── publisher.rb # policies/*.md → policy_versions (idempotent)
24
+ │ ├── release.rb # `belt policies publish`: new version + db/migrate file
25
+ │ ├── version_record.rb # mixed into the app's PolicyVersion model
26
+ │ ├── acceptance_record.rb # mixed into the app's PolicyAcceptance model
27
+ │ ├── controller.rb # index/show/status/accept actions
28
+ │ ├── require_acceptance.rb # before_action guard
29
+ │ └── templates/ # ERB copied into apps
30
+ ├── belt/generators/policies_generator.rb # belt g policies (install)
31
+ ├── belt/generators/policy_generator.rb # belt g policy <kind>
32
+ └── belt/commands/policies_command.rb # belt policies list|kinds|publish
33
+ ```
34
+
35
+ ## Design rules
36
+
37
+ - **Models live in the app**, not the gem. `belt setup tables` builds
38
+ `dynamodb.tf` by reading `lambda/models/*.rb` with regexes. The generated
39
+ model files must keep a literal `class X < ApplicationRecord` and a
40
+ single-quoted `indexes(...)` call.
41
+ - **The gem talks to models through a few class methods**: `find_version`,
42
+ `all_versions`, `create_version!`, `for_user`, `record!`. Specs use
43
+ in-memory fakes of exactly those (`spec/support/fake_models.rb`). Add a
44
+ method to the record concern and the fake together.
45
+ - **Published text is immutable.** `Publisher` never overwrites a version. It
46
+ reports `:changed`. `Release` writes a new version.
47
+ - **Publishing happens in data migrations** (`belt db:migrate`, run by
48
+ `belt deploy`), so every environment gets the same versions with no extra
49
+ deploy hook. Each migration just calls `Belt::Policies.publish!`, which is
50
+ idempotent.
51
+ - **Acceptance rows are append-only**, apart from `account_deleted_at`.
52
+
53
+ ## Workflow
54
+
55
+ ```bash
56
+ bundle install
57
+ bundle exec rspec
58
+ bundle exec rubocop # CI blocks on offenses
59
+ ```
60
+
61
+ Try it in a scratch app:
62
+
63
+ ```bash
64
+ belt new demo && cd demo
65
+ echo 'gem "belt-policies", path: "../belt-policies"' >> Gemfile && bundle
66
+ bundle exec belt g policies --with tos,privacy
67
+ bundle exec belt policies list
68
+ ```
69
+
70
+ User-facing changes go in `CHANGELOG.md`. Bumping
71
+ `lib/belt/policies/version.rb` on `master` releases to RubyGems
72
+ (`.github/workflows/auto-tag.yml`). Don't bump without a release decision.
73
+
74
+ <!-- feature-parity:begin -->
75
+ ## FeatureParity (this project tracks feature parity)
76
+
77
+ This repo is tracked by [FeatureParity](https://featureparity.dev) as project
78
+ **`belt-policies`** (see `.featureparity.yml`). Requirements are shared across every
79
+ surface the product ships on, and CI enforces that they stay covered — a pull request
80
+ that lets a covered requirement go red will fail the `Parity gate` check.
81
+
82
+ Two things are expected of you on every change, not just when someone asks:
83
+
84
+ 1. **A feature or fix ships with a test bound to its requirement** (`fp:<slug>`).
85
+ 2. **The requirements stay true.** If none covers what you're building, propose one.
86
+ If one is wrong or outdated, propose the change — never edit or ignore it.
87
+
88
+ **Start every task by checking the requirements — before you write code, not
89
+ after.** When you pick up a feature or bug, first inventory the full request and
90
+ compare it with the active and draft requirements. Find what already covers the work,
91
+ what must change, and what is missing. Treating this as the opening move — not a chore
92
+ at the end — is what keeps the catalog trustworthy.
93
+
94
+ ### 1. Find and model the requirements — do this first
95
+
96
+ ```bash
97
+ fp list # active requirements (project comes from .featureparity.yml)
98
+ fp list --status draft # proposed work — check before creating duplicates
99
+ fp list --gaps # the ones with no evidence yet — good places to start
100
+ fp show <slug> # details, acceptance criteria, and existing receipts
101
+ fp history <slug> # why it says what it says, and whether a change is pending
102
+ fp changes --source-url URL # requirement activity prompted by a work item
103
+ ```
104
+
105
+ **The requirement catalog is the human-readable product specification, not a box to
106
+ tick with one umbrella record.** It records the team's current shared understanding;
107
+ it is not a finished decree handed from product to engineering. Refine requirements
108
+ through ongoing conversation with customers, domain experts, and builders. The human
109
+ product owner decides value and priority; agents should surface gaps, inconsistencies,
110
+ alternatives, and useful splits rather than silently making product decisions.
111
+
112
+ Before coding, enumerate the independently meaningful promises in the request:
113
+ user-visible behaviours, policies, limits, plans, deadlines, state transitions,
114
+ permissions, failure modes, and affected surfaces. Then model them:
115
+
116
+ - Give each independently testable or independently failable product promise its own
117
+ focused requirement. If one acceptance sentence joins behaviours that could pass or
118
+ fail separately, split it.
119
+ - Use a parent for the broad capability and children for distinct behaviours, policies,
120
+ or surface-specific promises. Do not create requirements for implementation details
121
+ or refactors that make no product promise.
122
+ - Put concrete product decisions — prices, quotas, dates, roles, fallbacks — in the
123
+ requirement acceptance criteria. Never leave them only as assumptions in code or a PR.
124
+ - Check adjacent requirements and their history. New requirements do not excuse an old
125
+ requirement that the work makes inaccurate; propose a change to keep the whole catalog true.
126
+ - Before finishing, reconcile the implementation against the catalog: every shipped
127
+ behaviour maps to a current requirement, every changed promise is recorded, and every
128
+ focused requirement has appropriate marked evidence or an explicit gap. A broad marker
129
+ must never appear to prove several acceptance clauses that its test does not exercise.
130
+
131
+ **Prevent contradictions before they enter the catalog.** Before proposing a new or changed
132
+ promise, compare its intended acceptance criteria with related active and draft requirements
133
+ and read their history. Ask whether both promises can be true at the same time for the same
134
+ user, state, and conditions. If not, do not add another requirement.
135
+
136
+ A failing test bound to an existing requirement, or an implementation that only works by
137
+ changing one, is a contradiction signal — not permission to weaken the test or write a
138
+ second requirement around it. Identify which promise matches product intent, then use
139
+ `fp propose-change` (or `--removal`) with the source and reason to put that decision before
140
+ a human. If the intent is unclear, stop and ask rather than making the catalog say both things.
141
+
142
+ **Treat existing tests as protected product evidence.** Before editing an existing test,
143
+ determine what expectation it already records and whether CI has passing evidence for it.
144
+
145
+ - If the test backs passing evidence, do not rewrite it to make the current implementation
146
+ or a new requirement fit. Explicitly flag the intended test edit in the work item and pull
147
+ request. If its expected behaviour would change, propose the requirement change and wait
148
+ for explicit human approval before editing the test; a newly green run must not hide that
149
+ the proof itself changed.
150
+ - When FeatureParity reports a changed bound test, run `fp changed-tests` (or add `--json`)
151
+ and compare the accepted and candidate source revisions with the requirement. Record the
152
+ exact candidate through `fp changed-tests assess <id> --candidate-fingerprint <sha256>`
153
+ with a plain-language explanation and one classification: `still_proves`,
154
+ `no_longer_proves`, or `uncertain`. The first resolves only that fingerprint; the others
155
+ remain open for a human product decision. A later fingerprint needs a new assessment.
156
+ - A new requirement does not retroactively own or supersede a pre-existing failing test —
157
+ whether the requirement is draft or active. Do not change that test, or adopt it as the new
158
+ requirement's proof, merely to make the new work pass. Surface the conflict and wait for
159
+ explicit human approval to change the existing expectation.
160
+
161
+ A changed-test finding is an audit signal alongside the testcase's independent CI outcome.
162
+ It is not a failed test and is separate from `suspect`, which means requirement wording
163
+ changed and its evidence needs re-verification. Neither permits rewriting a test before
164
+ product intent is decided.
165
+
166
+ If no requirement covers what you're building, propose one or a parent/child tree. Agents
167
+ always create **drafts**; a human activates them:
168
+
169
+ ```bash
170
+ fp propose --slug <slug> --name "..." --required <surface>,<surface> \
171
+ --why "..." --acceptance "..." \
172
+ --source-type fizzy --source-url <card-or-issue-url>
173
+ ```
174
+
175
+ **Write a requirement as a product decision someone can approve, not as a design
176
+ task.** It should read like a capable teammate wrote it: direct, specific, and free
177
+ of boilerplate such as “ensure a seamless experience.”
178
+
179
+ - **Title:** one clear product promise in present tense, with a concrete subject and
180
+ active verb. Name the user-visible capability, not the work (`“Dispatchers see
181
+ current shipment status”`, not `“Implement status polling”`).
182
+ - **Why:** one natural sentence naming who needs this, the problem or risk, and the
183
+ outcome worth protecting. Do not repeat the title, narrate the request or bug
184
+ history, sell the feature, or explain the mechanism.
185
+ - **Acceptance:** one independently verifiable rule of observable behaviour that lets a
186
+ reviewer decide whether the promise is true. State the trigger or situation, the result, and material
187
+ boundaries that qualify that same promise. Given/When/Then may clarify those parts,
188
+ but it is not a mandatory template. Split a second result that could fail
189
+ independently into its own requirement instead of comma-chaining it here.
190
+ - **Example:** supply one concise concrete case that would pass the general rule, and
191
+ put it after the rule in Acceptance as `Example: ...`. Use the domain's real terms
192
+ and representative data, but ask rather than invent consequential policy. Do not mistake a few examples for the rule
193
+ or let marked tests stand for every case; neither replaces the general rule and
194
+ continued conversation.
195
+ - **Numbers and policy:** include a price, limit, timeout, date, role, or fallback only
196
+ when the source made it a real product decision. Never invent a plausible value to
197
+ fill a gap. If the decision matters and the source is silent, ask.
198
+ - **Implementation detail:** leave out components, data models, algorithms, endpoints,
199
+ and UI controls unless that interface or constraint is itself part of the promise.
200
+
201
+ Learn from the request, existing requirements and history, and available evidence about
202
+ what users actually do. Look for what they attempt, where they abandon a task, what they
203
+ use, and what experiments reveal. Treat the first proposed solution as input, not
204
+ unquestionable scope. Preserve important uncertainty as a question rather than
205
+ laundering a guess into acceptance criteria.
206
+
207
+ State the promise itself rather than commentary about it. Avoid openings such as “This
208
+ feature…”, “This requirement…”, “The goal is…”, “Users should be able to…”, and “The
209
+ system should…”. Use the real actor or product as the subject and an active verb.
210
+
211
+ - ✅ **Title:** `Dispatchers see current shipment status`
212
+ **Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
213
+ **Acceptance:** `When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current.`
214
+ - ❌ **Title:** `Implement Redis-backed 30-second status polling`
215
+ **Why:** `This important feature ensures a seamless, robust experience.`
216
+ **Acceptance:** `Use a background worker and refresh every 30 seconds.`
217
+ This invents policy, prescribes a design, and never states the product outcome.
218
+
219
+ Before proposing, answer three questions against the actual fields: Who is this promise
220
+ for? How would the concrete example prove it done? Why does the outcome matter? If any
221
+ answer is missing or vague, revise the draft or ask the user. Then read it as a manager deciding scope:
222
+ is the value clear, can the result be observed, are product decisions
223
+ distinguished from guesses, and does every sentence earn its place? If the draft fails
224
+ this review, revise it or ask before writing the record.
225
+
226
+ ### 2. If the requirement is wrong, propose a change — don't silently edit it
227
+
228
+ Shipping something that contradicts a requirement means one of the two is wrong.
229
+ Say which, in the record:
230
+
231
+ ```bash
232
+ fp propose-change <slug> --why "..." --acceptance "..." \
233
+ --reason "what prompted this" \
234
+ --source-type fizzy --source-url <card-or-issue-url>
235
+
236
+ fp propose-change <slug> --removal --reason "superseded by <other-slug>"
237
+ ```
238
+
239
+ What happens: the change is applied, the requirement drops back to **draft**, and a
240
+ human reviews the old-vs-new diff. Rejecting restores the previous wording verbatim.
241
+ What it *was* is kept, so nothing is lost either way.
242
+
243
+ Rules worth knowing before you try:
244
+
245
+ - Never edit a requirement's wording by any other route. `fp propose-change` is the
246
+ only one that records what it was and what prompted the change.
247
+ - You may correct `--name` (the title) or `--slug` through a proposal. Existing evidence
248
+ follows a proposed slug, but every `fp:<slug>` marker still needs the same correction.
249
+ You still cannot remove a required surface.
250
+ - You cannot approve or reject — not even your own proposal.
251
+ - One open proposal per requirement (409 otherwise). Run `fp history <slug>` first.
252
+ - **A requirement awaiting review still counts toward the gate.** Proposing a change
253
+ is not a way to make a red gate green; it only marks the existing evidence
254
+ `suspect`, because the tests were written against wording that has moved.
255
+
256
+ ### 3. Mark the test
257
+
258
+ Add an `fp:<slug>` comment directly above the test. Keep the test's own name
259
+ readable — the marker is the join, not the name.
260
+
261
+ ```ruby
262
+ # fp:print_container_qr
263
+ it 'prints a QR code onto the container label' do
264
+ ```
265
+
266
+ Pin surfaces in the marker when one test satisfies more than one:
267
+ `# fp:print_container_qr@api,web`.
268
+
269
+ Every feature needs one. A requirement with no marked test is a gap, and gaps are
270
+ what the gate is eventually going to fail on.
271
+
272
+ ### 4. Report the evidence
273
+
274
+ ```bash
275
+ fp report --junit junit.xml --surface <surface> --repo <org/repo> \
276
+ --work-item <card-or-issue-url>
277
+ ```
278
+
279
+ Report `present` or `stub` only — **never** claim `passing`/`failing`. Those are CI's
280
+ to write, via `fp ci-report`, and the gate trusts them precisely because agents can't
281
+ set them.
282
+
283
+ If you just changed a requirement's wording and re-wrote its tests, re-report the
284
+ evidence: that's what clears `suspect`.
285
+
286
+ ### 5. Confirm you didn't break the gate
287
+
288
+ ```bash
289
+ fp check # exits non-zero if this project now violates its level
290
+ ```
291
+
292
+ `fp check --json` if you want to parse the violations. Run `fp help` for the full
293
+ command list.
294
+ <!-- feature-parity:end -->
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 (unreleased)
4
+
5
+ First release.
6
+
7
+ - `belt g policies` installs `PolicyVersion` / `PolicyAcceptance` models (and so
8
+ the `policy_versions` / `policy_acceptances` tables), a policies controller,
9
+ and routes: `GET /policies`, `GET /policies/{slug}`, `GET /policies/status`,
10
+ `POST /policies/accept`
11
+ - `belt g policy <kind>` outlines a policy as a draft in `policies/<slug>.md`.
12
+ There are 12 built-in kinds, and any other slug gets a blank outline.
13
+ Outlines only, not legal advice
14
+ - `belt policies list | kinds | publish`. `publish` versions a policy, clears
15
+ draft, and writes the data migration that publishes it on `belt deploy`
16
+ - `Belt::Policies.accept!`, `status_for`, `pending_for`, `accepted?`,
17
+ `history_for`, `account_deleted!`
18
+ - `Belt::Policies::RequireAcceptance` before_action guard
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stowzilla
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,192 @@
1
+ # belt-policies
2
+
3
+ Versioned policies for [Belt](https://belt.dev) apps: Terms of Service,
4
+ Privacy Policy, Cookie Policy, and whatever else you publish. Plus the record
5
+ of **who agreed to which version, when, how, and from where**.
6
+
7
+ > [!IMPORTANT]
8
+ > **This is not legal advice.** belt-policies is plumbing. It generates
9
+ > *outlines* (headings, plus TODO prompts for what usually goes under them) and
10
+ > records acceptances. It does not write your policies and cannot tell you
11
+ > what they must say for your business or jurisdiction. Nothing in this gem was
12
+ > written or reviewed by a lawyer. Have a qualified attorney write or review
13
+ > your policies before you rely on them.
14
+
15
+ ## What you get
16
+
17
+ - `belt g policies` installs two models, a controller, four routes, and the
18
+ DynamoDB tables (through `belt setup tables`)
19
+ - `belt g policy <kind>` outlines a policy as a **draft** markdown file in
20
+ `policies/`
21
+ - `belt policies publish <slug>` gives it a new version and writes a data
22
+ migration. `belt deploy` runs the migration, which copies the exact text into
23
+ `policy_versions`
24
+ - An API your frontend calls to show the current policies, check what the
25
+ signed-in user still has to accept, and record their acceptance
26
+ - A `before_action` that holds signed-in users until they've accepted the
27
+ current versions
28
+
29
+ ## Install
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem 'belt-policies'
34
+ ```
35
+
36
+ ```bash
37
+ bundle install
38
+ belt g policies --with terms-of-service,privacy-policy
39
+ ```
40
+
41
+ ```
42
+ create lambda/models/policy_version.rb
43
+ create lambda/models/policy_acceptance.rb
44
+ create lambda/controllers/api/policies_controller.rb
45
+ create policies/README.md
46
+ update config/routes.rb (policy routes in gateway :api)
47
+ update infrastructure/modules/app/dynamodb.tf
48
+ create policies/terms-of-service.md (draft)
49
+ create policies/privacy-policy.md (draft)
50
+ ```
51
+
52
+ Options: `--gateway NAME` (default: the first gateway in `config/routes.rb`),
53
+ `--auth NAME` (default: `cognito`), `--skip-routes`, `--force`.
54
+
55
+ ## Write, publish, deploy
56
+
57
+ ```bash
58
+ belt policies kinds # what `belt g policy` can outline
59
+ belt g policy cookies # → policies/cookie-policy.md (draft)
60
+
61
+ $EDITOR policies/terms-of-service.md # write the real text, delete every TODO
62
+
63
+ belt policies publish terms-of-service # version = today, drops draft,
64
+ # writes db/migrate/<ts>_publish_terms_of_service_<version>.rb
65
+ belt deploy dev # the migration publishes it to policy_versions
66
+ belt policies list # version and state of each file
67
+ ```
68
+
69
+ `publish` won't publish while the file still has `TODO` placeholders (use
70
+ `--force` for a dev-only test). It won't republish text that hasn't changed
71
+ either. For a typo fix, use `--minor`: people who accepted an earlier version
72
+ aren't asked again.
73
+
74
+ Never edit a published version in place. The stored text is what people
75
+ agreed to. If a file changes without a new version, the publish step leaves
76
+ the stored copy alone and says so, and `belt policies list` shows
77
+ *edited since published*.
78
+
79
+ ### Policy files
80
+
81
+ ```markdown
82
+ ---
83
+ title: Terms of Service
84
+ version: '2026-11-02'
85
+ effective_date: '2026-11-02'
86
+ requires_acceptance: true # false: published only (cookie policy, accessibility)
87
+ requires_reacceptance: true # false: a minor edit; earlier acceptances still count
88
+ draft: true # removed by `belt policies publish`
89
+ ---
90
+ # Terms of Service
91
+ …
92
+ ```
93
+
94
+ Built-in kinds: `terms-of-service`, `privacy-policy`, `cookie-policy`,
95
+ `acceptable-use-policy`, `community-guidelines`, `refund-policy`, `eula`,
96
+ `seller-agreement`, `data-processing-agreement`, `sms-terms`,
97
+ `accessibility-statement`, `do-not-sell`. Aliases work (`tos`, `privacy`,
98
+ `cookies`, `aup`, `dpa`, …). Any other slug gets a blank outline:
99
+ `belt g policy beta-terms --title "Beta Program Terms"`.
100
+
101
+ ## API
102
+
103
+ | Route | Auth | Returns |
104
+ |-------|------|---------|
105
+ | `GET /policies` | none | `{ policies: [{ policy, version, title, effective_date, requires_acceptance, … }] }` |
106
+ | `GET /policies/{slug}` | none | `{ policy: { …, content, current } }`. Add `?version=` for an older version |
107
+ | `GET /policies/status` | signed in | `{ policies: [{ …, accepted, pending, accepted_version, accepted_at }], pending: ['terms-of-service'] }` |
108
+ | `POST /policies/accept` | signed in | Same as status, after recording |
109
+
110
+ ```js
111
+ // Sign-up form: the box is ticked, so record what the person was shown.
112
+ await api.post('/policies/accept', {
113
+ policies: shown.map(p => ({ policy: p.policy, version: p.version })),
114
+ via: 'signup',
115
+ })
116
+ ```
117
+
118
+ Send the version you displayed. If a newer version was published in the
119
+ meantime, the API answers **409** with `details.current_version`, records
120
+ nothing, and you show the new text. Accepting a version again records nothing
121
+ new. Email comes from the token's claims. IP address and user agent come from
122
+ the request.
123
+
124
+ ### Hold users until they accept
125
+
126
+ ```ruby
127
+ module ApiControllers
128
+ class ApplicationController < BeltController::Base
129
+ include Belt::Policies::RequireAcceptance
130
+ before_action :require_current_policies!
131
+ end
132
+ end
133
+ ```
134
+
135
+ A signed-in user with pending policies gets **403**
136
+ `{ error, details: { code: 'policies_pending', pending: [...] } }`. The
137
+ policies routes themselves stay open. Routes behind the guard need
138
+ `tables: %i[policy_versions policy_acceptances]` so the Lambda can read them.
139
+
140
+ ## Ruby API
141
+
142
+ ```ruby
143
+ Belt::Policies.current # { 'terms-of-service' => PolicyVersion, … }
144
+ Belt::Policies.current_version('privacy-policy')
145
+ Belt::Policies.pending_for(user_id) # current versions still to accept
146
+ Belt::Policies.accepted?(user_id) # nothing pending
147
+ Belt::Policies.status_for(user_id) # what GET /policies/status returns
148
+ Belt::Policies.accept!(user_id:, policies: ['terms-of-service'], via: 'signup',
149
+ email:, ip_address:, user_agent:)
150
+ Belt::Policies.history_for(user_id) # every acceptance, newest first
151
+ Belt::Policies.account_deleted!(user_id) # stamp rows for a retention job
152
+ ```
153
+
154
+ ```ruby
155
+ Belt::Policies.configure do |c|
156
+ c.cache_ttl = 60 # seconds a Lambda caches current versions
157
+ c.policies_dir = 'policies' # where the markdown lives
158
+ c.version_model = 'PolicyVersion'
159
+ c.acceptance_model = 'PolicyAcceptance'
160
+ end
161
+ ```
162
+
163
+ ## Tables
164
+
165
+ **`policy_versions`**: one row per published version, keyed
166
+ `<policy>@<version>`. Holds `title`, `effective_date`, `published_at`,
167
+ `requires_acceptance`, `requires_reacceptance`, the full `content`, and its
168
+ `content_sha256`. GSI `PolicyIndex` (`policy`, `publishedAt`).
169
+
170
+ **`policy_acceptances`**: one row each time someone agreed. Holds
171
+ `user_id`, `email`, `policy`, `version`, `policy_version_id`, `content_sha256`,
172
+ `accepted_at`, `accepted_via`, `ip_address`, `user_agent`, and
173
+ `account_deleted_at`. GSIs `UserIndex` (`userId`, `createdAt`) and
174
+ `PolicyIndex` (`policy`, `createdAt`). Rows are never edited, apart from the
175
+ deletion stamp.
176
+
177
+ The models live in your app (`lambda/models/`), so `belt setup tables` can see
178
+ them and you can add to them.
179
+
180
+ ## Remove
181
+
182
+ ```bash
183
+ belt d policies # models, controller, routes; keeps policies/*.md and db/migrate
184
+ belt d policy cookie-policy
185
+ ```
186
+
187
+ Removing the models drops both tables on the next deploy, unless deletion
188
+ protection is on.
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../policies'
4
+ require_relative '../policies/release'
5
+
6
+ module Belt
7
+ module Commands
8
+ # `belt policies …` — work with the policy files in policies/.
9
+ class PoliciesCommand
10
+ def self.description
11
+ 'List, outline, and publish policies (belt-policies)'
12
+ end
13
+
14
+ def self.run(args)
15
+ new.run(args.dup)
16
+ end
17
+
18
+ def run(args)
19
+ case args.shift
20
+ when 'list', 'ls', nil then list
21
+ when 'kinds' then kinds
22
+ when 'publish' then publish(args)
23
+ when 'help', '--help', '-h' then help
24
+ else
25
+ warn "Unknown command. Run `belt policies help`.\n"
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def dir
33
+ Belt::Policies.configuration.policies_dir
34
+ end
35
+
36
+ def list
37
+ docs = Belt::Policies::Document.all(dir)
38
+ if docs.empty?
39
+ return puts("No policies in #{relative(dir)}/. Outline one with `belt g policy terms-of-service`.")
40
+ end
41
+
42
+ puts 'POLICY VERSION ACCEPT STATE'
43
+ docs.each do |doc|
44
+ puts format('%-30<slug>s %-14<version>s %-10<accept>s %<state>s',
45
+ slug: doc.slug, version: doc.version, accept: doc.requires_acceptance? ? 'required' : '-',
46
+ state: state(doc))
47
+ end
48
+ rescue Belt::Policies::InvalidDocument => e
49
+ abort "Error: #{e.message}"
50
+ end
51
+
52
+ def state(doc)
53
+ return 'draft' if doc.draft?
54
+ return 'edited since published — run `belt policies publish`' if doc.edited_since_published?
55
+ return 'ready (no publish migration yet — run `belt policies publish`)' if doc.published_sha256.nil?
56
+
57
+ 'published'
58
+ end
59
+
60
+ def kinds
61
+ Belt::Policies::Catalog.all.each do |kind|
62
+ accept = kind.requires_acceptance ? 'acceptance required' : ''
63
+ puts format('%-28<slug>s %-48<title>s %<accept>s', slug: kind.slug, title: kind.title, accept: accept)
64
+ puts "#{' ' * 29}aliases: #{kind.aliases.join(', ')}"
65
+ end
66
+ puts "\nAny other slug works too: belt g policy my-policy --title \"My Policy\""
67
+ end
68
+
69
+ def publish(args)
70
+ slugs = args.reject { |a| a.start_with?('--') || option_value?(args, a) }
71
+ abort 'Usage: belt policies publish SLUG [SLUG …] [--minor] [--version V] [--force]' if slugs.empty?
72
+
73
+ slugs.each do |slug|
74
+ result = Belt::Policies::Release.new(
75
+ slug: slug, policies_dir: dir, root: Dir.pwd,
76
+ version: option(args, '--version'), minor: args.include?('--minor'), force: args.include?('--force')
77
+ ).call
78
+ doc = result.document
79
+ puts " update #{relative(doc.path)} (version #{doc.version}" \
80
+ "#{', no re-acceptance' unless doc.requires_reacceptance?})"
81
+ puts " create #{relative(result.migration_path)}"
82
+ end
83
+ puts "\nRun `belt deploy <env>` (or `belt db:migrate <env>`) to publish."
84
+ rescue Belt::Policies::Error => e
85
+ abort "Error: #{e.message}"
86
+ end
87
+
88
+ def help
89
+ puts <<~HELP
90
+ Usage: belt policies COMMAND
91
+
92
+ #{Belt::Policies::NOT_LEGAL_ADVICE}
93
+
94
+ Commands:
95
+ list Policies in policies/ with version and state
96
+ kinds Policies `belt g policy` can outline
97
+ publish SLUG [...] Give it a new version (today's date), clear draft, and
98
+ write the db/migrate file that publishes it on deploy
99
+ --minor Don't ask people who accepted an earlier version again
100
+ --version V Use V instead of today's date
101
+ --force Publish even with TODO placeholders left
102
+
103
+ Related:
104
+ belt g policies Install (models, tables, routes, controller)
105
+ belt g policy KIND Outline a policy as a draft
106
+ HELP
107
+ end
108
+
109
+ def option(args, name)
110
+ index = args.index(name)
111
+ return args[index + 1] if index
112
+
113
+ args.find { |a| a.start_with?("#{name}=") }&.split('=', 2)&.last
114
+ end
115
+
116
+ def option_value?(args, arg)
117
+ index = args.index(arg)
118
+ index&.positive? && args[index - 1] == '--version'
119
+ end
120
+
121
+ def relative(path)
122
+ path.delete_prefix("#{Dir.pwd}/")
123
+ end
124
+ end
125
+ end
126
+ end