Design · 002-downstream-consumers

Downstream consumers

How we will build it — and where ADR-0007 is born.

Status Accepted Spec 002-downstream-consumers Owner Position Keeping team Branch main Created Read time

Position mutation already flows through a port (001). We make the persistence adapter the single writer and the single emitter of PositionChanged, and record that as ADR-0007 (D-007) — with its fitness function (an ArchUnit boundary rule + a Semgrep content rule) authored at the same time — plus a defense-in-depth DB-grant backstop (D-008). This is the decision every later feature is checked against.

1 · Principles check

Ports-and-adapters (001) keeps the domain framework-free; this feature adds an outbound port for the event and makes the adapter the sole writer. The emission guarantee is enforced by a fitness function, not by hope — the whole point of ADR-0007.

2 · Technical context

Language / runtime
Java 21 on the Gradle build 001 bootstrapped (001/D-001); JDK-only domain.
Architecture
Hexagonal — the persistence adapter is the single writer + emitter; consumers subscribe to an in-memory bus.
Enforcement
ArchUnit (boundary) + Semgrep (content) + a DB grant (operational) — layered, per ADR-0007.

3 · Data model & contracts

No external contract. PositionChanged is an internal event (instrument, quantity, tradeId). The persistence port stays the only write surface; the event is emitted inside the same transaction (spec NFR-001).

5 · Grounding & evidence

ClaimSourceStatusFinding
ArchUnit can express "only package X calls the data-access API"ArchUnit layered/onion rule DSLverifiedThe adapter package is named as the only one permitted to reach the data-access classes.
A content rule catches a direct UPDATE positions outside the adapterSemgrep pattern UPDATE\s+positions, path-excluded to the adapterverifiedCatches the residual SQL case ArchUnit's type-graph misses.
A database GRANT can refuse UPDATE positions from any role but the adapter'sSpike — H2 2.3.232: GRANT SELECT, INSERT, UPDATE ON positions TO pk_adapter, GRANT SELECT … TO pk_readerverifiedAs pk_reader the update fails with SQL state 90096 (Not enough rights for object "PUBLIC.POSITIONS"); as pk_adapter it updates one row. Grounds D-008; GrantBackstopTest pins it in the build.
A committed position and its emitted event must not divergeSettlement domain — KB-0001@2026-09-13verifiedDownstream consumers reconcile against a consistent book; a silent write breaks that consistency, which is why emission is guaranteed at the boundary.

6 · Approach

Add an outbound event port; the persistence adapter emits PositionChanged on every write, inside the transaction. Risk and P&L subscribe to a synchronous in-memory bus. Make the adapter the single writer and pin that with a fitness function (ADR-0007) so no later feature can add a second writer without failing the gate.

7 · Alternatives considered

OptionNo silent changeEnforceableSimpleScore
Adapter is the single writer + emitter (chosen)✓✓ ArchUnit + Semgrep✓9
Consumers poll the store✗ a missed poll is silent—✓3
Emit from the use case, not the adapterpartial✗ two write paths possible✓5

8 · Decisions

D-006 · Synchronous in-memory event bus for this milestone

Context. Durability is a later concern (spec out-of-scope). Decision. Emit synchronously inside the mutation transaction over an in-memory bus. Consequences. + A committed position and its event never diverge. − No replay yet.

src/**/persistence/** xyz.briancorbin.pk.hex.persistence.. Every position change must emit PositionChanged so Risk, P&L, and the audit trail stay consistent; a direct write to the position store bypasses the emission and the audit hooks that live in the adapter.

D-007 · ADR-0007 — only the persistence adapter touches the data store

Context. Spec FR-004 requires a single emitter; the emission guarantee is only as strong as the boundary around the write. The domain reason is settlement consistency (KB-0001@2026-09-13): the book and its consumers must never diverge silently. Decision. Only xyz.briancorbin.pk.hex.persistence may reach the data-access APIs or write the positions store; enforced by an ArchUnit boundary rule now, a Semgrep content rule for the residual SQL case (added when JDBC arrives, feature 003), and a DB grant as the operational backstop (D-008). Consequences. + Every position change provably emits the event; a new writer fails the gate. − Legitimate new persistence code lives in the adapter package or extends the scope through a recorded amendment. Amended 13 Sep 2026. The decision now also names the store it governs and its owner — spectastic://briancorbin/position-keeper-guardrails/datastore/positions, owned by this service — so that in any other service a touch of the store is a violation even when cleanly layered in that service's own adapter: the defect there is ownership, not layering.

db/** Even if a static rule is evaded, the database itself refuses a write to positions from any role but the adapter's.

D-008 · DB-grant backstop for the positions table

Context. Static rules can be evaded; a defense-in-depth layer belongs at the database. Grounded by the H2 grant spike in §5: a role without the grant is refused (90096), the adapter's role is not. Decision. A GRANT limits writes on positions to the adapter's role — db/grants.sql, applied by H2Database.open (003), and pinned by GrantBackstopTest. Consequences. + A final backstop below the static checks. − Not statically checkable at merge time — recorded as a none-with-reason so coverage reports it honestly; the runtime test proves the grant, not the diff.

9 · Project structure

src/main/java/xyz/briancorbin/pk/hex/core/                       # domain: positions, PositionChanged + EventBus port (JDK-only)
src/main/java/xyz/briancorbin/pk/hex/events/SyncEventBus.java     # D-006: synchronous in-memory bus
src/main/java/xyz/briancorbin/pk/hex/consumers/                  # RiskExposureView, PnlRevaluer (idempotent on tradeId)
src/main/java/xyz/briancorbin/pk/hex/persistence/                # the single writer + emitter (ADR-0007's scope)
src/test/java/xyz/briancorbin/pk/enforcement/DataAccessRulesTest.java  # the ArchUnit fitness function for D-007 (writes SARIF)
src/test/java/xyz/briancorbin/pk/enforcement/SarifReport.java    # ArchUnit EvaluationResult → SARIF 2.1.0 (no emitter ships with ArchUnit)
src/test/java/xyz/briancorbin/pk/hex/persistence/GrantBackstopTest.java  # D-008's runtime proof against H2
db/schema.sql                                                     # positions + applied_trades DDL
db/grants.sql                                                     # D-008: GRANT UPDATE ON positions TO pk_adapter
enforcement/semgrep-positions.yaml                                # the Semgrep content rule for D-007

10 · Risks & mitigations

A later feature adds a second writer, bypassing emission

Likelihood high over time (the Priya scenario), impact high. Mitigation: ADR-0007's fitness function fails the gate on any writer outside the adapter — the whole reason it is a decision with a check, not a comment.

11 · Complexity tracking

None beyond the layered enforcement, which is the point rather than accidental complexity.

12 · Open questions

None outstanding.

13 · Change log

  1. Status flipped Draft → Accepted on 18 Sep 2026 — zero remaining unchecked tasks (14/14); ./gradlew build green with DownstreamConsumersTest (SC-001), PnlRevaluerTest (SC-002), DataAccessRulesTest and GrantBackstopTest verified passing per author confirmation. Sibling bundle (REQ-LIFECYCLE-005).
  2. D-008 grounding assumed → verified by an H2 grant spike (state 90096 for a non-adapter role); its enforcement stays a none-with-reason. Structure block lists the bus, consumers, SARIF writer, grant test and db/ DDL the tasks will land.
  3. Reset to Draft — re-driven through tasks → implement → verify against a real Gradle build; the 2024–25 dates in docs/timeline.md remain the narrated history.
  4. D-007 amended with a resource scope (spectastic 119): the decision names the positions store by coordinate and this service as its owner, so a non-owner service's touch — however well layered — is a violation. The owner-side verdict is unchanged (demonstrations 1–7 reproduce byte-identically); demonstration 8 exercises the non-owner case.
  5. Design accepted — the adapter becomes the single writer + emitter; ADR-0007 (D-007) authored with its ArchUnit fitness function, plus the DB-grant backstop (D-008). Grounds the emission guarantee in settlement consistency (KB-0001).