Change proposal · 007-reconciliation

Accept corrections from a downstream service over HTTP

Give the reconciliation service a sanctioned door — POST /corrections — so the change it wants routes through the owner instead of into the store.

Status Applied Change ID 2026-09-18-http-corrections Spec 007-reconciliation Author Position Keeping team Created Targets 1 requirement Read time

Adds one requirement, FR-005: the position keeper accepts a correction over POST /corrections and applies it through the position use case, exactly as the nightly job does. It exists because demonstration 8's verdict on the consumer service says “route the change through its owner” — and until now the owner had no route to offer. Additive; no decision changes; ADR-0007 is honoured the same way the job honours it.

Accept corrections from a downstream service over HTTP

Applied

1 · Intent

The consumer service (acme/reconciliation-service, demonstration 8) computes corrections of its own and, in the demonstrated bypass, writes them into the positions store from its own adapter. The verdict flags that as an ownership violation and its explanation says: route the change through the owning service. That advice is only actionable if the owner exposes a way in. Today it does not — the nightly job is the only correction path, and it is in-process.

This change gives the owner that door. A correction over HTTP is the same operation the job performs — bring an instrument to a target quantity under a supplied tradeId — so it lands on PositionService.correct, emits PositionChanged through the adapter, and stays idempotent on the caller's tradeId. Nothing about ADR-0007's boundary moves.

2 · Scope

In scope

  • POST /corrections with {instrument, quantity, tradeId}, answering the resulting position.
  • The OpenAPI contract for it, authored first at specs/007-reconciliation/contracts/corrections.openapi.yaml and promoted to api/corrections.openapi.yaml on apply; declared in the design's §3.
  • Idempotency on the caller's tradeId — a repeat is a 200 that changes nothing and emits nothing.
  • A handler test against a live server asserting the event, the store and the idempotent repeat.

Out of scope

  • Authentication and authorisation of the caller — the spec defers auth to the gateway (004); a downstream service's identity is the same question.
  • Batch corrections (many instruments per request) — the nightly job already handles the batch case in-process; one instrument per request is enough for the consumer's needs today.
  • The consumer's side — its PositionKeeperClient calling this endpoint — lives in the second service's own bundle.

3 · Approach

A third handler on the existing JDK HTTP server (004/D-001): hex.http.CorrectionsHandler parses the JSON body — three fields, hand-parsed like the rest of the surface — and calls PositionService.correct(instrument, quantity, tradeId) (007 T-010), answering Json.position of the result. A malformed body is 400; any method but POST is 405. Main registers it. The handler holds the use case only, never a repository — the same read-only-by-construction discipline as 004, applied to a write path: the only write it can cause is the one the adapter performs and emits.

The design's §3 already declares the contract (amended for this change) so that /spectastic.apply's promotion step has a declared effective path to write to — the first contract promotion this project performs through the verb rather than by hand.

4 · Deltas

One <spec-delta>: an ADD.

The service MUST accept a correction from a downstream reconciliation service over POST /corrections — instrument, the custodian quantity that service reconciles against, and a synthetic tradeId identifying that service's reconciliation run — and apply it through the position use case, so it emits PositionChanged like every other mutation; a repeated tradeId MUST change nothing and answer the current position.

Rationale

ADR-0007's verdict on a non-owner service tells it to route the change through the owner. This is the route. It reuses the job's correction path so that there is still exactly one way a position changes.

4b · What this change removes

Each entry names one thing this change takes away — a clause dropped from a requirement it retypes, an obligation a sibling loses, a behaviour or affordance no requirement records. An empty register is the answer “this removes nothing”, so the common case needs no editing.

5 · Risk register

Populated by the adversarial risk pass (the ADD is must-tier). One <spec-risk> per finding.

The new door is a write endpoint with no caller identity check at all — deferring auth to a gateway that doesn't exist yet means, until that gateway ships, any network caller (not only the vetted reconciliation service) can set any instrument to any quantity under any tradeId, which is a materially larger blast radius than the in-process nightly job this reuses ever had.

§2 out-of-scope: "Authentication and authorisation of the caller — the spec defers auth to the gateway (004); a downstream service's identity is the same question." — deferred, not resolved, and FR-005 itself carries no caller constraint.

Accepted, eyes open. The whole HTTP surface (004's reads included) defers caller identity to the gateway under TBD-auth, and the process binds localhost by default, so the door is not network-reachable until someone deliberately exposes it. The correct fix is the gateway, not a one-off check on this route; the risk is recorded so that exposing the port is a decision, not an accident.

FR-005 lets the HTTP caller supply its own tradeId for idempotency and attribution, but the live FR-003 requires a correction to carry "a synthetic tradeId identifying the reconciliation run" — a downstream service's own transaction id identifies its own action, not a reconciliation run, so a correction arriving over POST /corrections cannot satisfy both requirements' plain text at once.

FR-003: "A correction MUST carry a synthetic tradeId identifying the reconciliation run, so consumers stay idempotent and the audit trail attributes the change."

Mitigated by rewording the delta: FR-005 now requires "a synthetic tradeId identifying that service's reconciliation run" — the caller is a reconciliation service and its run id is a reconciliation run id, so FR-003's attribution and idempotency hold for corrections from either source. The consumer's client (010) formats it the same way the job does: recon:<sha8>:<instrument>.

The in-scope item generalises the correction door to "a downstream service" as a class, but 007-reconciliation is scoped to the nightly custodian-file job; the actual, named consumer of this HTTP surface is 010-reconciliation-service (called out in the same §2's non-goals) — this looks like a capability that belongs to that consuming spec, or to 002-downstream-consumers as a write-surface amendment, rather than a requirement bolted onto the nightly job's FR list.

§2 in-scope: "POST /corrections with {instrument, quantity, tradeId}, answering the resulting position." — generalised past the nightly job's custodian-file semantics that the rest of 007's requirements (FR-001, FR-003, FR-004) are written around.

Rejected on placement, with the point about generality taken. D-007 names this project as the store's owner; "route the change through its owner" means the owner specifies the door and the consumer's spec specifies the client — the consumer cannot add a requirement to a service it does not own. 007 is the spec whose subject is corrections and whose use case (correct) the door reuses, so it is the right owner-side home; 002 is about emission, not correction. The generality concern is answered by the reworded FR-005, which now names a downstream reconciliation service reconciling against a custodian quantity, not any caller with a number.

FR-001 rests on the domain fact that a correction's authority comes from the custodian's settled end-of-day file being compared against the trade-date book; FR-005 creates a second, ungoverned correction source — a downstream service's own computed target quantity, with no requirement it derives from any custodian snapshot — which contradicts that domain grounding rather than extending it.

KB-0001@2026-09-13: "Reconciliation compares the position keeper's trade-date book against the custodian's settled snapshot and corrects the difference," against FR-001: "The job MUST read the custodian end-of-day file and compute... the delta between the custodian quantity and the stored quantity."

Mitigated in the same rewording: the quantity a caller sends is "the custodian quantity that service reconciles against", so the authority is still the custodian's settled snapshot — held by a different service, but the same source of truth KB-0001 names. The endpoint cannot verify that (it has no custodian file); the requirement states it and the consumer's spec is where it is proven.

6 · Tasks

Scoped to this change only. On /spectastic.apply these fold into the target's tasks.html as a new provenance-linked phase (per REQ-CHANGE-006) that /spectastic.implement drains — this list is then the frozen intent record.

  • Test the corrections endpoint — start HttpApi on port 0 with CorrectionsHandler over an in-memory repository, bus and recorder; POST /corrections {"instrument":"AAPL","quantity":100,"tradeId":"c-1"} → 200 {"instrument":"AAPL","quantity":100} and exactly one PositionChanged(AAPL, 100, c-1); the same body again → 200 and still one event; {"instrument":"AAPL"} → 400; GET /corrections → 405 src/test/java/xyz/briancorbin/pk/hex/http/CorrectionsEndpointTest.java
  • Implement the corrections handler — POST only (405 with Allow: POST); parse the three fields from the JSON body (400 naming the missing or malformed field); positions.correct(instrument, quantity, tradeId); 200 with Json.position src/main/java/xyz/briancorbin/pk/hex/http/CorrectionsHandler.java
  • Register the route and exercise it — Main adds .handle("/corrections", new CorrectionsHandler(positions)); ./gradlew run then curl -si -X POST localhost:8080/corrections -d '{"instrument":"AAPL","quantity":100,"tradeId":"c-1"}' → 200 and a PositionChanged log line; the same request again → 200 and no new line src/main/java/xyz/briancorbin/pk/Main.java

7 · Open questions

None outstanding — the endpoint's shape follows 004's, its semantics follow the job's, and the out-of-scope items each carry a destination.

8 · Change log

  1. Adversarial risk pass: four findings (the corpus angle fired on KB-0001). FR-005 reworded so the tradeId identifies the caller's reconciliation run and the quantity is the custodian's; the unauthenticated-door risk accepted with rationale; the placement concern rejected — the owner specifies the door.
  2. Proposal authored — one additive requirement giving the owner a sanctioned correction route for downstream services; the contract sidecar authored alongside and declared in the design.
  3. Applied on 18 Sep 2026 — Added FR-005 — a downstream reconciliation service can bring an instrument to its custodian quantity over POST /corrections, through the same use-case path the nightly job uses; the corrections contract promoted to api/corrections.openapi.yaml.