T-024 — rail-proxy: the fencing gate for a resource that cannot check
Picking this up? Read
CONTRIBUTING.mdfirst, then claim the matching issue and work on a branch. Finished means all six definition-of-done gates, not five. If anything below disagrees with a contract, the contract wins — open a contract change issue instead of implementing either version. Update this task’s row in the ledger in the same pull request.
Milestone M2 · Estimate 30 min (tight; if rail_high_water is not already migrated by T-020, split the migration out as T-024a)
Preconditions — T-001..T-008 (Gradle monorepo, catalog, build-logic, Spotless, CI); T-020..T-023 (paydb Flyway tree incl. rail_submission + rail_high_water, payment-resource claim/post with fence point (a), rail-stub with its chaos knobs). You inherit a repo where paydb exists and the stub is reachable, but nothing stands between an executor and the rail — any token, however stale, can move money.
Goal — Implement rail-proxy as the only process permitted to call the rail, gating every submission behind a durable per-account high-water mark checked before the rail is touched.
1. Why this task exists
Enforcement point (a) protects a Postgres row, which can compare tokens because it is a database. The rail cannot: no token, no idempotency key, no memory (#ct3-railstub). So the fence is relocated into a process we do control, in front of it — and the mark it compares against must outlive a crash, a rollout and an Autopilot eviction, because those are exactly the events that manufacture stale writers. This is enforcement point (c), and the reason the project has a third process instead of a HashMap.
2. Contracts to obey
| What | Pinned by |
|---|---|
POST /v1/rail/submissions shape; all six error codes; the four-step admission order; why in-memory/per-replica/read-replica marks are wrong |
docs/contracts/C3-http-surfaces.md#ct3-railproxy |
Timeout means unknown, never retry; strict > not >= |
docs/contracts/C3-http-surfaces.md#ct3-ambiguity |
rail_submission / rail_high_water columns; rail_submission_attempt_uidx; the ON CONFLICT … WHERE highest_token < EXCLUDED.highest_token RETURNING upsert |
docs/contracts/C1-database-schemas.md#ct1-paydb, #ct1-fenced |
X-Fencing-Token, X-Owner-Id, X-Idempotency-Key |
docs/contracts/C3-http-surfaces.md#ct3-conventions |
| Proxy→rail hop timeout and zero retries | docs/contracts/C3-http-surfaces.md#ct3-timeouts |
rail.submission{outcome}, rail.duplicate.attempted, lock.fenced.out{resource=rail}; logs fenced_out, duplicate_rail_submission_attempted, rail_ambiguous + required fields |
docs/contracts/C4-observability.md#ct4-metrics, #ct4-zero, #ct4-logs, #ct4-cardinality |
Module name, package dev.lock.rail.proxy, paydb datasource key |
docs/contracts/C5-config-build-and-naming.md#ct5-modules, #ct5-config |
Precedence: if this spec and a contract disagree, the CONTRACT wins — stop, report both wordings, implement neither. Contract silence is not permission to invent a name.
3. Deliverables
| Path | What |
|---|---|
rail-proxy/build.gradle.kts |
Boot app; catalog aliases only; depends on lock-api — not lock-client, lock-server or payment-resource |
rail-proxy/src/main/java/dev/lock/rail/proxy/RailProxyApplication.java |
Entry point |
.../web/RailSubmissionController.java + request/response records |
The single endpoint; header binding; failure→pinned-code mapping |
.../admission/AdmissionService.java |
Steps 1–4 in order; owns transaction boundaries |
.../store/HighWaterStore.java, .../store/RailSubmissionStore.java |
Plain JDBC over the two tables; no ORM |
.../rail/RailClient.java |
One-shot forward, explicit read timeout, no retry |
.../RailProxyMetrics.java |
Micrometer counters, closed tag-value sets from C4 |
.../health/RailStubHealthIndicator.java |
The railStub indicator C4 #ct4-health pins for this service only: reachability of the configured rail base URL, DOWN on any failure |
rail-proxy/src/main/resources/application.yml |
Port, paydb datasource, rail base URL, rail.proxy.fencing.enabled declared, default true (wiring is T-026). Health groups: readiness includes railStub so the pod fails closed rather than admitting submissions it cannot forward; liveness touches neither the rail nor paydb |
settings.gradle.kts |
Include the module if T-004 left it commented out |
4. Specification
(1) Fence first. In one transaction, run the pinned conditional upsert of rail_high_water for accountId with the presented token. An unchanged/zero-row result means the token is not strictly greater than the stored mark → write a rail_submission row with outcome='FENCED', emit fenced_out (resource=rail), lock.fenced.out{resource=rail}, rail.submission{outcome=fenced}, return 409 FENCED_OUT. No rail call is made on this path — that is the whole point.
(2) Duplicate check. Reject a second forwardable attempt for a payoutId with 409 DUPLICATE_SUBMISSION, rail.duplicate.attempted, and duplicate_rail_submission_attempted carrying firstSubmittedAt. Treat the rail_submission_attempt_uidx unique violation as the same outcome — the index, not a prior SELECT, is the authority, so a race cannot slip through.
(3) Intent before socket. Commit the row with outcome NULL before any connection is opened, so a proxy crash still leaves evidence that a submission may have been presented. A commit failure is 503 RAIL_UNAVAILABLE — safe, because nothing was presented.
(4) Forward exactly once and resolve the row: ack → ACKED + rail_reference + resolved_at, 200; decline → REJECTED, 422; timeout or dropped connection → TIMEOUT, log rail_ambiguous, 409 RAIL_AMBIGUOUS. No retry, no fallback, no second socket, on any outcome.
Every attempt — fenced, duplicate, acked, rejected, ambiguous — leaves exactly one row carrying presented_token. That table is the audit trail T-027 reads.
5. Acceptance criteria
./gradlew :rail-proxy:buildpasses, Spotless clean.- A strictly-greater token returns 200 and advances
rail_high_water.highest_tokento it. - A lower or equal token returns 409
FENCED_OUTwith arail_submissionrowoutcome='FENCED'. - On the fenced path the stub records no inbound call (assert against the stub’s call log, not proxy logs).
- A repeat
payoutIdreturns 409DUPLICATE_SUBMISSIONand incrementsrail.duplicate.attempted. - With
rail.stub.latency-msabove the read timeout: 409RAIL_AMBIGUOUS, rowTIMEOUTwithresolved_at, exactly one row. - Restarting the proxy between the two submissions does not re-admit the lower token — criterion 3 still holds after restart.
grep -rn "HashMap\|ConcurrentHashMap" rail-proxy/src/main/javafinds no high-water storage./actuator/prometheusexposesrail_submission_total,rail_duplicate_attempted_total,lock_fenced_out_totalwith only pinned tag values; nopayoutIdas a tag.
6. Verification
./gradlew :rail-proxy:build :rail-proxy:test
./gradlew :rail-proxy:bootRun # with rail-stub running
curl -si localhost:8083/v1/rail/submissions -H 'X-Fencing-Token: 42' -H 'X-Owner-Id: w1' \
-H 'X-Idempotency-Key: k1' -H 'Content-Type: application/json' -d '{…}' # 200
curl -si localhost:8083/v1/rail/submissions -H 'X-Fencing-Token: 41' … # 409 FENCED_OUT
psql "$PAYDB_URL" -c 'select outcome, presented_token, rail_reference from rail_submission order by submitted_at'
psql "$PAYDB_URL" -c 'select * from rail_high_water'
curl -s localhost:8083/actuator/prometheus | grep -E 'rail_submission_total|rail_duplicate'
Expected: one ACKED row, one FENCED row, mark = 42, stub logged one call.
7. Out of scope
The executor that calls this proxy (T-025); making rail.proxy.fencing.enabled=false actually bypass the check (T-026); the cross-module duplicate/ledger test (T-027); Cloud SQL/Terraform/workload identity (M5); dashboards and PodMonitoring (M6).
8. Hazards
Forwarding inside the intent transaction — it pins a paydb connection for the stub’s injected latency and, worse, a rollback erases the evidence that money may have moved. Advancing the mark after the rail call re-opens the exact hole this task closes. Auto-retrying a timeout: #ct3-ambiguity — “submit again to be safe” turns a possible single payment into a probable double payment, and no WHERE fence < clause can un-pay a beneficiary. >= instead of > admits an equal-token replay.
9. On completion
Mark the T-024 row done in tasks/README.md; record any deviation, and any contract mismatch you had to stop on, in that row’s notes.