Skip to content

Commit 94ef978

Browse files
foaeclaude
andcommitted
Feedback v2: processing lifecycle, server-side dedupe, canonical companion skill
Server (migration 002: processed_at, payload_hash): - frictions: content-hash duplicate absorption within 24h -> 200 + existing row; the auto-collected context object is excluded from identity - reviews: replay with different content -> 409 replay_mismatch instead of silently discarding the new payload - processed_at + POST /api/v1/submissions/processed (batch mark/unmark with per-id outcomes), processed list filter, friction category/summary/project/ harness surfaced on list rows - generous length caps (protect indexed columns, never police content) - /ready pings Postgres; deploy script verifies /ready instead of /health - drop spoofable RealIP middleware; untrack committed build binary Companion skill, canonical at skills/agent-feedback/ (distribution handled outside this repo; SKILL.md documents installation): - submit-friction: --stdin JSON mode, --dry-run, machine-readable outcome lines, auto-collected context (occurred_at, cwd, git repo/branch/commit/ dirty, os/arch, session id, agent, effort, profile, client version) - harness-agnostic attribution: markers verified from each harness's installed code (claude-code/opencode/pi/omp/codex), inner-harness-wins nesting, no prefix sniffing (profile API-key leaks), AGENT_FEEDBACK_HARNESS/MODEL/ SESSION_ID overrides - submit-review: 409 handling, honest exit codes; query: URL-encoded and read-only by default; new process.sh (list/done/undo) - simplified spool: both types retry-safe, .inflight pruned, identity-checked replays Docs and tests: - docs/agent-usage.md rewritten to match reality (base URL, run_id recipe, new contract); CLAUDE.md rules updated (four-artifact API changes, write-once with the processed_at exception) - e2e: rerun-safe unique IDs, mktemp, 33 checks incl. 413/409/dedupe/processed - hermetic skill suite (tests/skill/, Python mock server, 41 checks) + shellcheck wired into CI; image build now depends on both test jobs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f916882 commit 94ef978

37 files changed

Lines changed: 3033 additions & 207 deletions

.github/workflows/ci.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,19 @@ jobs:
4646
TEST_POSTGRES_URL: postgres://postgres:test@127.0.0.1:5432/feedback_test?sslmode=disable
4747
run: go test -race -count=1 ./...
4848

49+
skill:
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v4
53+
- name: shellcheck client scripts
54+
run: |
55+
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
56+
cd skills/agent-feedback/scripts && shellcheck -x ./*.sh
57+
- name: hermetic client tests (mock server)
58+
run: bash tests/skill/run-tests.sh
59+
4960
image:
50-
needs: test
61+
needs: [test, skill]
5162
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
5263
runs-on: ubuntu-latest
5364
steps:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,6 @@ backend/vendor
3232
# Editor/IDE
3333
# .idea/
3434
# .vscode/
35+
36+
# Local build output (just build)
37+
backend/bin/

CLAUDE.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ your task needs it. If you are instead **submitting data to the running service*
1414
|---|---|
1515
| Wire a skill/producer to submit feedback | [docs/agent-usage.md](docs/agent-usage.md) — self-contained API contract, curl recipes |
1616
| Change service code | This file (rules below), then [docs/architecture.md](docs/architecture.md) for the layer model |
17-
| Change the API surface | Rules below — three artifacts must move together |
17+
| Change the API surface | Rules below — four artifacts must move together |
18+
| Change the companion skill (client scripts) | `skills/agent-feedback/` — this repo is the canonical source; run `tests/skill/run-tests.sh` |
19+
| Install/adopt the companion skill | `skills/agent-feedback/SKILL.md` — Installation section |
1820
| Understand naming/error/test conventions | [docs/conventions.md](docs/conventions.md) |
1921
| See how the service was scaffolded | [docs/adding-a-service.md](docs/adding-a-service.md), [docs/patterns.md](docs/patterns.md) |
2022
| Run / verify / deploy | Commands + Verification below; deploy runbook in [README.md](README.md) ("Deploy to production") |
@@ -26,11 +28,15 @@ backend/ Go module root (single go.mod)
2628
pkg/ shared infra (postgres pool, envutil, httputil, observability)
2729
services/feedback/
2830
cmd/feedback/main.go wiring: config, DI, chi router, graceful shutdown
29-
core/ business logic: validation, idempotent create, list/get
31+
core/ business logic: validation, content hashing/dedupe, create, list/get, processed
3032
handler/ HTTP: auth middleware, DTOs, sentinel-error → status mapping
3133
storage/postgres/ q.sql + migrations/ (inputs) → sqlc/ (generated, never edit)
3234
infra/agent-feedback/ docker-compose stack (postgres + service)
3335
scripts/e2e.sh live-contract test suite (all endpoints)
36+
skills/agent-feedback/ CANONICAL companion skill (SKILL.md + client scripts ONLY —
37+
distributed as-is; sync/distribution handled outside this repo)
38+
tests/skill/ hermetic tests for the skill scripts (repo-only; must NEVER
39+
travel with the skill directory)
3440
docs/agent-usage.md THE API contract (producers integrate against this)
3541
```
3642

@@ -44,6 +50,7 @@ just test # unit tests (integration tests skip without TEST_POSTGRES_
4450
just run-local # needs .env (cp .env.example .env)
4551
just sqlc-generate # regenerate storage/postgres/sqlc/ after editing q.sql or migrations
4652
bash scripts/e2e.sh <API_KEY> [BASE_URL] # from repo root, against a running stack
53+
bash tests/skill/run-tests.sh # hermetic client-script tests (mock server)
4754
```
4855

4956
Integration tests need a real Postgres:
@@ -64,14 +71,20 @@ docker rm -f pg-test
6471
`just sqlc-generate`. Never hand-edit generated files.
6572
- **Migrations are append-only** now that the service is deployed. New numbered
6673
file per change; never rewrite an existing one.
67-
- **An API change is a three-artifact change**: handler/core code,
68-
[docs/agent-usage.md](docs/agent-usage.md), and `scripts/e2e.sh` move in the
69-
same commit. The doc is a load-bearing contract — producer agents build their
70-
recipes from it without reading the code.
71-
- **Write-once semantics are deliberate.** No update endpoints. Reviews dedupe on
72-
`(submission_type, run_id)` — replay returns the existing row with 200. The
73-
skill name `friction` is reserved. Don't add PATCH; a correction is a new
74-
submission.
74+
- **An API change is a four-artifact change**: handler/core code,
75+
[docs/agent-usage.md](docs/agent-usage.md), `scripts/e2e.sh`, and the client
76+
scripts in `skills/agent-feedback/` move in the same commit. The doc is a
77+
load-bearing contract — producer agents build their recipes from it without
78+
reading the code.
79+
- **Write-once payloads, one mutable flag.** Submission content is never
80+
updated; `processed_at` (set/cleared via `POST /api/v1/submissions/processed`)
81+
is the only mutable state. Reviews dedupe on `(submission_type, run_id)`:
82+
identical replay → 200 existing row; different content under the same key →
83+
409 `replay_mismatch` (hash comparison via `payload_hash`) — a correction is a
84+
new submission under a new run_id. Frictions dedupe on content hash within a
85+
24 h window (`core.frictionDedupeWindow`) → duplicate returns 200 + existing
86+
row; the auto-collected `context` object is EXCLUDED from that hash (it varies
87+
between attempts of the same friction). The skill name `friction` is reserved.
7588
- **Identity PK is a deliberate exception** to the template's UUID convention
7689
(see note in [docs/conventions.md](docs/conventions.md)).
7790
- Create endpoints are strict: unknown JSON fields → 400, bodies over 10 MiB → 413.
@@ -98,3 +111,6 @@ docker rm -f pg-test
98111
3. If you touched the API surface: `docker compose up -d` in `infra/agent-feedback/`
99112
(needs `.env`, see `.env.example`), then `bash scripts/e2e.sh <key>` — all
100113
checks green — then `docker compose down -v` to remove test data.
114+
4. If you touched `skills/agent-feedback/`: `bash tests/skill/run-tests.sh`
115+
(hermetic — needs python3, no running stack). Keep the skill directory free
116+
of tests/tooling — it is distributed verbatim.

README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ retry semantics, and copy-pasteable recipes are all in
2626
## API
2727

2828
Endpoints: `POST /api/v1/reviews`, `POST /api/v1/frictions`,
29-
`GET /api/v1/submissions` (filtered list), `GET /api/v1/submissions/{id}`.
30-
Unauthenticated: `/health`, `/ready`, `/metrics` (Prometheus).
29+
`GET /api/v1/submissions` (filtered list), `GET /api/v1/submissions/{id}`,
30+
`POST /api/v1/submissions/processed` (batch mark/unmark by the feedback processor).
31+
Unauthenticated: `/health` (liveness), `/ready` (readiness incl. Postgres ping),
32+
`/metrics` (Prometheus).
3133

3234
The contract — request/response schemas per field, error bodies, status codes,
3335
idempotency, curl examples — lives in **[docs/agent-usage.md](docs/agent-usage.md)**
@@ -51,6 +53,8 @@ agent-feedback/
5153
│ └── .env.example
5254
├── infra/agent-feedback/ # docker-compose stack (postgres + feedback)
5355
├── scripts/e2e.sh # End-to-end contract suite (all endpoints)
56+
├── skills/agent-feedback/ # Canonical companion skill (SKILL.md + client scripts; see its Installation section)
57+
├── tests/skill/ # Hermetic tests for the skill scripts (repo-only, never distributed)
5458
└── docs/
5559
├── agent-usage.md # API contract for producer agents (start here for integration)
5660
├── architecture.md # Layer model, module strategy (template docs)
@@ -117,7 +121,9 @@ auth; olivier holds no registry credentials), streams it over SSH into
117121
`docker load`, syncs
118122
`infra/agent-feedback/docker-compose.deploy.yml` to `~/agent-feedback/` on
119123
olivier, generates credentials into `~/agent-feedback/.env` on first deploy
120-
(preserved on every later deploy), runs `docker compose up -d`, and health-checks.
124+
(preserved on every later deploy), runs `docker compose up -d`, and verifies
125+
`/ready` (which includes a Postgres ping — a deploy with a dead database fails
126+
the check instead of reporting healthy).
121127
Local prerequisites: `gh` (authed), `docker`, `crane` (`brew install crane`) —
122128
crane is used instead of `docker save` because docker's containerd image store
123129
can emit truncated save tars (see the script header).
@@ -131,6 +137,14 @@ bash scripts/e2e.sh <API_KEY> [BASE_URL] # BASE_URL defaults to http://127.0.0
131137
```
132138

133139
Exercises every endpoint against a live deployment: auth failures, create,
134-
idempotent replay, validation errors (bad values, unknown fields, oversized
135-
bodies), list filters, get-by-id. It creates test submissions — clean up with
136-
`docker compose down -v` (or delete the rows) afterwards.
140+
identical replay, replay-mismatch 409, friction duplicate absorption, validation
141+
errors (bad values, unknown fields, length caps, oversized bodies), list filters
142+
incl. `processed`, processed mark/unmark, get-by-id. Safe to rerun against a
143+
persistent database (unique ids per run); it creates test submissions — clean up
144+
with `docker compose down -v` (or delete the rows) afterwards.
145+
146+
The companion skill has its own hermetic suite (no stack needed):
147+
148+
```bash
149+
bash tests/skill/run-tests.sh
150+
```

backend/bin/feedback

-28.6 MB
Binary file not shown.

backend/services/feedback/cmd/feedback/main.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,11 @@ func main() {
137137
// Router
138138
r := chi.NewRouter()
139139

140+
// middleware.RealIP is deliberately absent: it rewrites RemoteAddr from
141+
// spoofable headers (X-Forwarded-For etc.) and nothing proxies this service.
140142
r.Use(
141143
middleware.Recoverer,
142144
middleware.RequestID,
143-
middleware.RealIP,
144145
buildLoggerMiddleware(logger, cfg.EnvMode),
145146
httputil.ReqMonitor(cfg.ServiceName),
146147
)
@@ -157,6 +158,17 @@ func main() {
157158
_, _ = w.Write([]byte("SHUTTING_DOWN"))
158159
return
159160
}
161+
// Readiness includes the database: a wedged Postgres must fail the
162+
// probe (and the deploy script's post-deploy check), not report READY
163+
// while every write spools client-side.
164+
pingCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
165+
defer cancel()
166+
if err := pg.DB().Ping(pingCtx); err != nil {
167+
slog.Warn("readiness probe failed: postgres unreachable", "error", err)
168+
w.WriteHeader(http.StatusServiceUnavailable)
169+
_, _ = w.Write([]byte("DB_UNAVAILABLE"))
170+
return
171+
}
160172
w.WriteHeader(http.StatusOK)
161173
_, _ = w.Write([]byte("READY"))
162174
})
@@ -169,6 +181,7 @@ func main() {
169181
api.Post("/reviews", h.HandleCreateReview())
170182
api.Post("/frictions", h.HandleCreateFriction())
171183
api.Get("/submissions", h.HandleListSubmissions())
184+
api.Post("/submissions/processed", h.HandleSetProcessed())
172185
api.Get("/submissions/{id}", h.HandleGetSubmission())
173186
})
174187

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package core
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"encoding/json"
7+
)
8+
9+
// reviewCanonical / frictionCanonical are the exact shapes hashed into
10+
// submissions.payload_hash. Struct field order fixes the JSON encoding, so
11+
// identical content always produces the same digest. created_at is deliberately
12+
// excluded — the hash identifies content, not the moment of receipt.
13+
type reviewCanonical struct {
14+
MachineName string `json:"machine_name"`
15+
CoordinatorModel string `json:"coordinator_model"`
16+
Payload reviewPayload `json:"payload"`
17+
}
18+
19+
type frictionCanonical struct {
20+
MachineName string `json:"machine_name"`
21+
CoordinatorModel string `json:"coordinator_model"`
22+
Payload frictionPayload `json:"payload"`
23+
}
24+
25+
// canonicalHash returns the sha256 hex digest of v's JSON encoding.
26+
func canonicalHash(v any) (string, error) {
27+
b, err := json.Marshal(v)
28+
if err != nil {
29+
return "", err
30+
}
31+
sum := sha256.Sum256(b)
32+
return hex.EncodeToString(sum[:]), nil
33+
}

0 commit comments

Comments
 (0)