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 +7 -0
- data/AGENTS.md +294 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +192 -0
- data/lib/belt/commands/policies_command.rb +126 -0
- data/lib/belt/generators/policies_generator.rb +229 -0
- data/lib/belt/generators/policy_generator.rb +120 -0
- data/lib/belt/policies/acceptance_record.rb +39 -0
- data/lib/belt/policies/catalog.rb +202 -0
- data/lib/belt/policies/configuration.rb +47 -0
- data/lib/belt/policies/controller.rb +77 -0
- data/lib/belt/policies/document.rb +135 -0
- data/lib/belt/policies/publisher.rb +53 -0
- data/lib/belt/policies/release.rb +118 -0
- data/lib/belt/policies/request_details.rb +32 -0
- data/lib/belt/policies/require_acceptance.rb +34 -0
- data/lib/belt/policies/templates/controllers/policies_controller.rb.erb +12 -0
- data/lib/belt/policies/templates/migrations/publish_policies.rb.erb +12 -0
- data/lib/belt/policies/templates/models/policy_acceptance.rb.erb +18 -0
- data/lib/belt/policies/templates/models/policy_version.rb.erb +17 -0
- data/lib/belt/policies/templates/policies/README.md.erb +54 -0
- data/lib/belt/policies/templates/policies/policy.md.erb +34 -0
- data/lib/belt/policies/version.rb +7 -0
- data/lib/belt/policies/version_record.rb +46 -0
- data/lib/belt/policies.rb +246 -0
- data/lib/belt-policies.rb +3 -0
- metadata +101 -0
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
|