Design · 002-downstream-consumers
Downstream consumers
How we will build it — and where ADR-0007 is born.
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
| Claim | Source | Status | Finding |
|---|---|---|---|
| ArchUnit can express "only package X calls the data-access API" | ArchUnit layered/onion rule DSL | verified | The 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 adapter | Semgrep pattern UPDATE\s+positions, path-excluded to the adapter | verified | Catches the residual SQL case ArchUnit's type-graph misses. |
A database GRANT can refuse UPDATE positions from any role but the adapter's | Spike — H2 2.3.232: GRANT SELECT, INSERT, UPDATE ON positions TO pk_adapter, GRANT SELECT … TO pk_reader | verified | As 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 diverge | Settlement domain — KB-0001@2026-09-13 | verified | Downstream 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
| Option | No silent change | Enforceable | Simple | Score |
|---|---|---|---|---|
| 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 adapter | partial | ✗ 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.
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.
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
- 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).
- D-008 grounding
assumed→verifiedby 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 anddb/DDL the tasks will land. - 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.
- 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.
- 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).