openspec: scan-limits-gating change — paid-order gating + daily/IP alert caps (no gateway)
This commit is contained in:
2
openspec/changes/scan-limits-gating/.openspec.yaml
Normal file
2
openspec/changes/scan-limits-gating/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-07
|
||||||
3
openspec/changes/scan-limits-gating/README.md
Normal file
3
openspec/changes/scan-limits-gating/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# scan-limits-gating
|
||||||
|
|
||||||
|
Alert gating: paid-order requirement (2014 late-payment model) + per-tag daily alert cap + per-IP rate limit — no payment gateway
|
||||||
40
openspec/changes/scan-limits-gating/design.md
Normal file
40
openspec/changes/scan-limits-gating/design.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The alert path already has location-aware throttling + fingerprint blocking. This change adds cost protection and the 2014 paid-gating model using existing schema only (no Stripe). The 2014 code suspended SMS on `is_paid`/late-payment; our equivalent is `orders.status != 'paid'`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Non-paid orders ⇒ no alert SMS (record scans only).
|
||||||
|
- Per-tag daily alert cap + per-IP hourly rate limit (configurable, env-overridable).
|
||||||
|
- Zero new schema, zero external services.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Payment gateway (Stripe/Paddle) — later; it will simply set `orders.status`.
|
||||||
|
- Plan tiers/entitlements beyond the daily/hourly caps.
|
||||||
|
- Admin UI changes (TagResource already shows the order relation).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Paid-gating in `shouldAlert`**: load the tag's order (via `tag.order_id` + a `GetOrderByID` query); if order exists and `status != 'paid'` → false. No order → proceed (transitional — most tags aren't sales-linked yet).
|
||||||
|
2. **Per-tag daily cap**: `CountAlertsByTagSince(tagID, now()-24h)`; `maxAlertsPerTagDay` default 5, env `ALERT_MAX_TAG_DAY`.
|
||||||
|
3. **Per-IP hourly limit**: need the scanner IP → `CountAlertsByIPSince(ip, now()-1h)`; but `scans` has no IP column! Options: (a) add `scans.ip TEXT` (idempotent ALTER) and record `r.RemoteAddr` host; (b) skip IP limit. Adding the column is cheap and matches the 2014 model (they tracked `ip_address`). **Decision: add `scans.ip` column** (idempotent) + record the client IP; `maxAlertsPerIPHour` default 10, env `ALERT_MAX_IP_HOUR`.
|
||||||
|
4. Ordering of checks in `shouldAlert`: sms_enabled → owner phone → fingerprint block → **paid-gating** → **daily cap** → **IP rate** → 10-min window rules → send.
|
||||||
|
5. Client IP: from `X-Forwarded-For` if present (behind Caddy), else `RemoteAddr` host.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Tags without orders keep alerting] → intentional transitional behaviour; documented.
|
||||||
|
- [IP capture behind proxy] → X-Forwarded-For parsing (Caddy sets it); falls back to RemoteAddr.
|
||||||
|
- [Cap constants too strict/loose] → env-overridable; no schema change to retune.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;` → `make db-up`.
|
||||||
|
2. sqlc: `GetOrderByID`, `CountAlertsByTagSince`, `CountAlertsByIPSince` → `make generate`.
|
||||||
|
3. Handlers: scan.go gating + IP capture; `InsertScan` gains IP.
|
||||||
|
4. Verify with extended suite; commit.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking.
|
||||||
27
openspec/changes/scan-limits-gating/proposal.md
Normal file
27
openspec/changes/scan-limits-gating/proposal.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The product needs cost protection and the 2014 paid-gating model, but a payment gateway (Stripe) is explicitly deferred. This change delivers the **mechanism** without the gateway: alerts only fire for tags whose linked order is `paid` (the 2014 `is_paid`/`late_payment` suspension), plus hard **per-tag daily alert caps** and a **per-IP rate limit** — all pure app logic on the existing schema. Stripe/Paddle plugs in later (Phase 5 full) by just setting `orders.status`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Paid-gating** (`sms-alerting`): if a tag has a linked order (`order_id` set) and that order's status is not `paid`, scans are recorded but **no SMS is sent** (owner sees "payments suspended" behaviour). No order yet → alerts allowed (transitional until sales flow exists).
|
||||||
|
- **Per-tag daily alert cap**: a tag can alert at most N times per 24 h (configurable constant, e.g. 5) — protects SMS cost against heavy repeated scanning.
|
||||||
|
- **Per-IP rate limit**: a scanner IP can trigger at most M alerts per hour (configurable) — guards against automation (works alongside the existing fingerprint block).
|
||||||
|
- **Admin visibility**: TagResource already links orders; the order status drives the gating — no new admin UI needed.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `alert-limits`: per-tag daily cap + per-IP hourly rate limit.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `sms-alerting`: ADDED — paid-order gating (no order = allowed, transitional; non-paid order = blocked).
|
||||||
|
- `database`: no schema change required (orders.status + tag.order_id already exist).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Go**: `internal/handlers/scan.go` (`shouldAlert` gains paid-gating + cap checks), `internal/db/queries.sql` (2 new count queries), regenerated sqlc.
|
||||||
|
- **Constants**: `maxAlertsPerTagDay`, `maxAlertsPerIPHour` (env-overridable via `ALERT_*` vars).
|
||||||
|
- No external services; no Stripe; no schema migration.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paid-order gating
|
||||||
|
When a tag has a linked order (`order_id` not null) whose status is not `paid`, scans SHALL be recorded but no alert SMS SHALL be sent. Tags without a linked order SHALL still alert (transitional).
|
||||||
|
|
||||||
|
#### Scenario: Lapsed order
|
||||||
|
- **WHEN** a scan arrives on a tag whose order status is `lapsed` or `pending`
|
||||||
|
- **THEN** the scan is recorded with `alert_sent = false` and no SMS is sent
|
||||||
|
|
||||||
|
#### Scenario: Paid order
|
||||||
|
- **WHEN** a scan arrives on a tag whose order is `paid`
|
||||||
|
- **THEN** normal alert rules apply
|
||||||
|
|
||||||
|
#### Scenario: No order yet
|
||||||
|
- **WHEN** a scan arrives on a tag with no linked order
|
||||||
|
- **THEN** normal alert rules apply (transitional behaviour)
|
||||||
|
|
||||||
|
### Requirement: Per-tag daily alert cap
|
||||||
|
A tag SHALL alert at most N times in any 24-hour window (N configurable, default 5). Excess scans are recorded without alerts.
|
||||||
|
|
||||||
|
#### Scenario: Cap reached
|
||||||
|
- **WHEN** a tag has already alerted N times in the last 24 h
|
||||||
|
- **THEN** further scans are recorded but no SMS is sent
|
||||||
|
|
||||||
|
### Requirement: Per-IP hourly rate limit
|
||||||
|
A scanner IP SHALL trigger at most M alerts per hour (M configurable, default 10). Excess alerts from the same IP are suppressed.
|
||||||
|
|
||||||
|
#### Scenario: Rate exceeded
|
||||||
|
- **WHEN** an IP has triggered M alerts in the last hour
|
||||||
|
- **THEN** further scan alerts from that IP are suppressed (scans still recorded)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paid-order gating in alert decision
|
||||||
|
The alert decision (`shouldAlert`) SHALL return false when the tag's linked order exists and is not `paid`.
|
||||||
|
|
||||||
|
#### Scenario: Lapsed tag scan
|
||||||
|
- **WHEN** a scan arrives on a tag with a non-paid order
|
||||||
|
- **THEN** `shouldAlert` returns false (record only)
|
||||||
|
|
||||||
|
### Requirement: Cap checks in alert decision
|
||||||
|
The alert decision SHALL respect the per-tag daily cap and per-IP hourly rate limit.
|
||||||
|
|
||||||
|
#### Scenario: Over cap
|
||||||
|
- **WHEN** the daily or hourly counter is exceeded
|
||||||
|
- **THEN** `shouldAlert` returns false (record only)
|
||||||
15
openspec/changes/scan-limits-gating/tasks.md
Normal file
15
openspec/changes/scan-limits-gating/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## 1. Schema & Queries
|
||||||
|
|
||||||
|
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up`
|
||||||
|
- [ ] 1.2 `queries.sql`: `GetOrderByID` (:one), `CountAlertsByTagSince` (:one, tag_id + since → count of alert_sent), `CountAlertsByIPSince` (:one, ip + since); `make generate`; build
|
||||||
|
|
||||||
|
## 2. Gating & Limits
|
||||||
|
|
||||||
|
- [ ] 2.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan`
|
||||||
|
- [ ] 2.2 `shouldAlert`: add paid-gating (order exists && status != 'paid' → false), per-tag daily cap (default 5, env ALERT_MAX_TAG_DAY), per-IP hourly cap (default 10, env ALERT_MAX_IP_HOUR) — checked in that order
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [ ] 3.1 Extend verify suite: lapsed-order tag → no alert; paid-order tag → alert; no-order tag → alert; tag over daily cap → no alert; IP over hourly cap → no alert
|
||||||
|
- [ ] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB
|
||||||
|
- [ ] 3.3 `openspec validate scan-limits-gating`; commit
|
||||||
Reference in New Issue
Block a user