Appearance
Phased Rollout Plan
System: Integration Platform (IIP) Related docs: Architecture · Use Cases · Data Model · Implementation Plan · Generalization Strategy
This document takes each Release's backlog (Implementation Plan §4) and breaks it into much smaller, sequential, individually-completable phases — small enough that each one is roughly a single sitting's work and a single commit. It's an execution checklist, not an architecture artifact: check items off as they land, and expect the phase breakdown for later releases to be revised once earlier ones are actually built (that's normal XP feedback, not drift).
Releases 1–6 are complete. The current line is Release 7, phase 7.1 — and per the re-sequencing in 06 §3, Release 3 was contract extraction rather than schema-registry plumbing. Releases 4–9 are broken down to the same granularity so the whole rollout is visible up front, but per the project's own "simple design" / YAGNI practice (Implementation Plan §1), don't start building Release 6's control-plane UI while Release 4 is still in progress — the phase numbers are a map, not a schedule.
Renumbering notice. Releases 3+ were re-sequenced when the platform generalized to multi-schema contracts. The old numbering (R3 Schema Registry, R4 Observability, R5 Mutable Lifecycle, R6 Extensibility, R7 Operability) no longer applies; nothing was dropped, only re-ordered and, in two cases, folded into the registry work. The Release 1–2 sections below are the historical record of what was actually built and are unchanged.
How to read this
- Phases are numbered
R.P(Release.Phase) and listed in build order within a release. - Each phase names the repo(s) it touches, what to build, and a tiny, concrete done-when check — usually one test or one observable behavior, matching the project's test-first practice.
- A phase that says "test-first" means: write the failing test in the same phase, before or alongside the implementation — not as a follow-up phase.
[ ]checkboxes are for tracking progress in this file as you go.
Spring Boot 4 / Jackson 3 gotchas hit so far
Discovered while building source-service (Phases 1.1–1.8), and worth watching for in db-adapter/file-adapter too (Spring Data JPA has had similar module-boundary changes in the same Spring Boot generation):
| Gotcha | What actually happened | Fix |
|---|---|---|
| Web starter renamed | spring-boot-starter-web is now spring-boot-starter-webmvc | Use the new artifact id (Spring Initializr will get this right if you regenerate) |
| Test starters split per-feature | The old monolithic spring-boot-starter-test is gone; each feature has its own -test starter (spring-boot-starter-webmvc-test, -kafka-test, -actuator-test, -validation-test, -restclient-test, …) | Add the specific -test starter for whatever you're slice-testing; missing one shows up as a compile error or a missing bean, not a clear message |
| Test annotations moved packages | @WebMvcTest → org.springframework.boot.webmvc.test.autoconfigure; TestRestTemplate → org.springframework.boot.resttestclient | Search the actual jar (unzip -l ... | grep ClassName.class) rather than guessing the package — guessed imports fail with "package does not exist," not a helpful redirect |
TestRestTemplate no longer auto-registered | @SpringBootTest(webEnvironment = RANDOM_PORT) alone doesn't give you a TestRestTemplate bean anymore | Add @AutoConfigureTestRestTemplate and the spring-boot-starter-restclient-test dependency (it needs RestTemplateBuilder, which nothing else pulls in) |
| Spring Boot's own default Jackson is now Jackson 3 | tools.jackson.core, not com.fasterxml.jackson.core — but Spring Kafka's JsonSerializer still uses legacy Jackson 2.x internally | Add com.fasterxml.jackson.datatype:jackson-datatype-jsr310 (the 2.x one) explicitly, or java.time fields fail to serialize with an InvalidDefinitionException about missing modules |
Jackson still defaults to WRITE_DATES_AS_TIMESTAMPS | Without disabling it, java.time fields serialize as numeric arrays/epoch numbers, not the ISO-8601 strings this project's canonical examples show | Build the Kafka value-serializer's ObjectMapper with JavaTimeModule registered and WRITE_DATES_AS_TIMESTAMPS disabled — via DefaultKafkaProducerFactoryCustomizer (org.springframework.boot.kafka.autoconfigure), not a hand-built ProducerFactory, so Spring Boot's own connection-detail handling stays intact |
@ServiceConnection bypasses spring.kafka.bootstrap-servers | Spring Boot wires the Testcontainers address directly into the autoconfiguration; the property itself still just reflects application.yml's fallback | Any raw test Kafka client needs its address from the KafkaContainer bean directly (kafkaContainer.getBootstrapServers()), never from @Value on that property |
Initializr's bootVersion id has a .RELEASE suffix that isn't a real artifact version | 4.1.0.RELEASE doesn't exist on Maven Central; the actual version is 4.1.0 | Strip the suffix from the generated pom.xml's parent version before the first build |
JsonDeserializer trusts the producer's __TypeId__ header by default | A cross-service consumer (db-adapter) can't resolve a header naming the producer's (source-service's) Java class, which isn't even on its classpath | .ignoreTypeHeaders() and always deserialize to the consumer's own local DTO — also the architecturally correct choice, since the JSON contract crossing the boundary shouldn't imply a shared Java type |
@Modifying @Query methods aren't auto-transactional | Unlike save() (transactional via SimpleJpaRepository), a custom native update/insert query throws TransactionRequiredException without one | Add @Transactional on the repository method itself |
The common thread: every one of these was invisible until something actually ran — compiling cleanly, and even a mocked unit test passing, told us nothing. Real Testcontainers integration tests and live docker compose checks caught all of them.
Release 1 — Walking Skeleton
Goal: docker compose up, submit one intern through the real UI, watch it land in Postgres and the CSV, with basic reliability (idempotency, isolated failures, a working DLQ) already true — not bolted on later.
Source Service
- 1.1 — Canonical record type.
source-service: add aCanonicalInternRecord(Javarecord) with the fields from Data Model §1 — no logic, just the shape. Done when: it compiles and a trivial construction test passes. - 1.2 — Intake DTO + validation.
source-service:CreateInternRequestDTO with Bean Validation annotations (@NotBlank,@Email,@NotNullonstartDate, etc.). Done when: a validator unit test shows a request missingemailfails validation and a fully-populated one passes. - 1.3 — Canonical mapper.
source-service:CanonicalMappermappingCreateInternRequest→CanonicalInternRecord, generatingrecordId(UUID) andcreatedAtserver-side. Done when: a unit test asserts two calls with identical input produce differentrecordIds and bothcreatedAts are non-null. - 1.4 — Kafka topics as code.
source-service:NewTopicbeans forintern.created(3 partitions) andintern.dlq(1 partition), matching Architecture §10 — makes topic creation part of the app, not justinfra's manual init job. Done when: app starts against a real broker and the topics exist with the right partition counts (verify viakafka-topics.sh --describe, same check already used againstinfra). - 1.5 — Kafka publisher.
source-service:KafkaEventPublisherwrappingKafkaTemplate<String, CanonicalInternRecord>(JSON), publishing keyed byinternId. Done when: a unit test with a mockedKafkaTemplateverifies the key and topic passed tosend(...). - 1.6 — POST /interns.
source-service:InternControllerwiring@Validrequest → mapper → publisher, returning202 AcceptedwithrecordId;400with field errors on invalid input. Done when: a@WebMvcTest/slice test covers both the happy path and a validation-failure path. - 1.7 — Publish integration test.
source-service: Testcontainers-Kafka test hittingPOST /interns(real HTTP call, real embedded broker) and asserting exactly one message onintern.createdwith the correct key. Done when: that test passes and would fail if the key were wrong (mutate it locally to confirm the test actually catches it, then revert). - 1.8 — GET /interns read path.
source-service: simplest correct read model owned by the Source Service itself (e.g. an in-memory or JPA-backed store populated at publish time) — not a query against Postgres/the CSV, preserving the loose-coupling boundary (Architecture §4.2). Done when: submitting viaPOSTand then callingGET /internsshows the new record.
Reviewed 2026-07-22 (Phases 1.1–1.8): all eight done-when checks re-verified — 23/23 tests green, the actual Docker image (not just
mvn spring-boot:run) rebuilt and exercised against a fresh Kafka viadocker compose,POST/GETround-tripped correctly, and the message read back off the real topic. One genuine Definition of Done gap found: item 5 (recordId/traceIdin logs at every new step) isn't met yet — there's no application-level logging at all insource-serviceso far. Not blocking (fulltraceIdpropagation is explicitly Release 4 / Phase 4.7 work), but plainrecordIdlogging could reasonably start now rather than waiting; left as an open gap rather than silently fixed, since it wasn't part of any phase's stated done-when criteria. Two doc inaccuracies fixed as part of this review: Architecture §5's Source Service diagram called the read modelInternQueryRepository(built asInternRecordStore) and didn't showApiExceptionHandler; UC-1 step 5 stated Schema Registry validation as already true (it's Release 3+, not yet built).
UI
- 1.9 — Intern form.
ui: replace the walking-skeleton placeholder with a real form (fields matching the canonical model) with client-side validation, callingPOST /internsviaVITE_API_BASE_URL. Done when: submitting a valid form in the browser gets a202and shows a confirmation; an invalid one shows inline errors without calling the API. - 1.10 — Records view.
ui: a list view callingGET /interns, rendering submitted records. Done when: a freshly submitted record appears in the list after a refresh.
Verified 2026-07-22 (Phases 1.9–1.10): both done-when checks confirmed against the real running stack (Kafka via
infradocker-compose,source-serviceonlocalhost:8080,uidev server) with a Playwright browser session, not just a build check. An empty-form submit produced 7 inline client-side errors and no API call; a valid submit returned202with a confirmation banner and the record appeared in the list without a manual refresh; light/dark themes (Catppuccin Latte/Mocha) and layout responsiveness at desktop (1400px), tablet (820px), and mobile (390px) widths were all checked, including the dark-mode preference persisting across a reload. One real bug found and fixed along the way: Spring Boot doesn't enable CORS by default, so the browser silently discarded the API's response even though the server processed it (confirmed viacurl—202with noAccess-Control-Allow-Originheader); fixed with aWebMvcConfigurer(WebConfig) insource-servicescoped to/interns/**, with allowed origins externalized viaiip.cors.allowed-origins. All 23source-servicetests re-run green after the fix.
Database Adapter
- 1.11 — JPA entity.
db-adapter:Internentity mapped to the existinginternstable (schema in theinfrarepo'spostgres/init.sql) + a Spring Data repository. Done when: the app starts against Postgres withddl-auto: validatepassing (no mismatch). - 1.12 — Kafka consumer (naive).
db-adapter:@KafkaListeneronintern.created, JSON-deserialize, map to entity,save(). Done when: publishing one message via the CLI or a test results in one row. - 1.13 — Make it idempotent.
db-adapter: replacesave()with a nativeINSERT ... ON CONFLICT (record_id) DO NOTHING(@Modifying @Query). Test-first: write the "deliver the same message twice, assert exactly one row" Testcontainers test before making it pass. Done when: that test passes.
Verified 2026-07-22 (Phases 1.11–1.13): all three done-when checks confirmed via real Testcontainers Postgres + Kafka, not mocks. 1.11:
init.sql(mirrored frominfrasince this repo can't reference a sibling repo's file) applied to a real Postgres,ddl-auto: validatepassed, entity save/find round-tripped correctly including a nullmentor. 1.12: a raw JSON message published directly tointern.created(not via any shared Java type --db-adaptermust never depend onsource-service's classes, only the canonical JSON contract) was consumed, deserialized, and landed as one row. 1.13, genuinely test-first: first confirmed what the naivesave()from 1.12 actually did under duplicate delivery -- it already avoided a second row, but only incidentally (a non-null@Idmakes Spring Data treat the entity as an update, not an insert, which isn't the same as a guaranteed atomic constraint) -- then replaced it with the explicit nativeON CONFLICT (record_id) DO NOTHINGupsert the phase calls for, confirmed via a real double-publish test. One gotcha hit and fixed along the way:@Modifying @Querymethods aren't automatically transactional the waysave()is viaSimpleJpaRepository-- needed an explicit@Transactionalon the repository method, or every call failed withTransactionRequiredException.
File Adapter
- 1.14 — Dedup store + CSV writer.
file-adapter: a local dedup store (recording processedrecordIds — an embedded file/SQLite is fine for R1) and aCsvInternWriterappending lines matching Data Model §4.2. Done when: a unit test writes two different records and asserts two distinct CSV lines. - 1.15 — Kafka consumer + idempotent append.
file-adapter:@KafkaListeneronintern.created: check the dedup store, skip-or-append-then-record. Test-first: "deliver the same message twice, assert exactly one CSV line." Done when: that test passes.
Reviewed 2026-07-22 (Phases 1.1–1.15, full re-check): every phase's done-when re-verified, not just re-read. Full suites re-run clean:
source-service23/23,db-adapter5/5,file-adapter10/10;uibuild + lint clean. Then a genuine end-to-end pass against the real Docker images (docker compose up --build, notmvn spring-boot:run) — submitted one intern both via raw HTTP and via a real browser session against the UI, and confirmed the identicalrecordIdlanded correctly in all three places:GET /interns(Source Service's own read path), a Postgres row, and a CSV line, with matching data in each. Two real doc inaccuracies found and fixed during this pass: Architecture's tech stack table claimed the file adapter's dedup index was "RocksDB/SQLite," but what's actually implemented (deliberately) is a flat file — file adapter is single-writer (AD-6), so there's no concurrent-writer race an embedded database would be needed for; and UC-5 claimed Schema Registry validation in its main flow, the same Release-1-vs-Release-3+ gap already caught and fixed for UC-1 in the earlier 1.1–1.8 review, just missed here sincedb-adapterdidn't exist yet at that time.
Verified 2026-07-22 (Phases 1.14–1.15): all done-when checks confirmed. 1.14: plain unit tests (no Testcontainers needed) covered the literal done-when plus two edge cases worth locking in — a college name containing a comma is RFC4180-quoted rather than corrupting the column count, and reopening an existing CSV file doesn't duplicate the header.
DedupStoreis a flat file (onerecordIdper line), not an embedded database — file-adapter runs as exactly one instance (Architecture AD-6), so there's no concurrent-writer race a heavier store would be needed for. 1.15: a real Testcontainers Kafka integration test published one message (one CSV line) and then the same message twice (still one line). One real bug caught during this phase, before it reached a commit: the bare context-load test (FileAdapterApplicationTests) had no path override, soCsvInternWriter/DedupStorebeans fell back toapplication.yml's default./data/...paths and wrote a real file into the repo's working directory on every test run — caught whendata/interns.csvshowed up as an untracked file about to be committed. Fixed by isolating that test with a@TempDir(matching the pattern already used in the Kafka consumer test) and addingdata/to.gitignore.
Reliability (still Release 1 — see Implementation Plan §4, not deferred)
- 1.16 — Failure isolation test.
db-adapter+file-adapter: Testcontainers test that stops Postgres mid-stream and asserts the File Adapter still succeeds independently while the DB Adapter's offset stays uncommitted (message not lost), then recovers once Postgres comes back. Done when: that test passes. - 1.17 — Shared FailureClassifier.
db-adapter+file-adapter: a small classifier distinguishing retriable vs. non-retriable exceptions, used identically in both adapters (Architecture §6). Done when: a table-driven unit test covers at least "connection refused → retriable" and "deserialization error → non-retriable." - 1.18 — Bounded retry.
db-adapter+file-adapter: wrap the write in the classifier + a bounded retry with backoff (config viaapplication.yml, not a hardcoded constant). Done when: a test asserts the retry count and that it gives up after the configured max. - 1.19 — DLQ on exhaustion/non-retriable.
db-adapter+file-adapter: on non-retriable failure or retry exhaustion, publish the original message + error metadata (Data Model §3) tointern.dlq, then commit the offset. Test-first: "publish a malformed message, assert it lands inintern.dlqand a subsequent good message still processes." Done when: that test passes for both adapters.
Verified 2026-07-22 (Phases 1.16–1.19): implemented in dependency order (1.17 → 1.18 → 1.19 → 1.16) since each builds on the last, then committed in the phase numbering order shown above.
db-adapter: 18/18 tests green (was 10).file-adapter: 19/19 tests green (was 10). Both adapters now deserialize the Kafka message by hand (ObjectMapper.readValueinside the listener) instead of via a KafkaJsonDeserializer— this was a deliberate design change from 1.12/1.15, not just an addition: it's what lets a malformed payload be classified and routed to the DLQ by the same code path as a write failure, and it removedKafkaConsumerConfig's wildcard-capture workaround entirely in both adapters.FailureClassifieris genuinely two different implementations sharing one shape, not one shared library —db-adapter's retriable set isConnectException/TransientDataAccessException,file-adapter's isConnectException/genericIOException(the realistic transient-failure analogue for a local-disk target), each adapter-appropriate. One real bug caught by the classifier's own test:file-adapter's first draft checked the retriable branch before the non-retriable branch, and since Jackson'sJsonProcessingExceptionis itself anIOExceptionsubtype, the broadIOExceptionretriable check silently shadowed the deserialization-error case — the deserialization-error test failed, not the connection-refused one, which is what caught it. 1.16 needed two attempts: stopping and restarting the same Testcontainers Postgres instance left connections failing for 60+ seconds afterstart()returned (container restart isn't a reliable simulate-recovery technique in practice); switched to Docker pause/unpause, which freezes the container without tearing down its network binding, and that worked cleanly.file-adapterdoesn't have its own 1.16 test — "the other target is unaffected" is a structural guarantee of Kafka consumer groups (separate groups on the same topic never block each other), already evidenced byfile-adapter's suite never touching Postgres at all; inventing a test that touches Postgres fromfile-adapter's repo would have been testing Kafka's architecture, not this codebase.
Proof & demo
- 1.20 — Full end-to-end test. One Testcontainers test: publish once (via the real HTTP path), assert a row appears in Postgres and a line appears in the CSV — the proof test called out in Original Specification §9.
Verified 2026-07-22 (Phase 1.20): lives in
infra/e2e-tests, a standalone Maven+JUnit+Testcontainers project (no Spring Boot — it only orchestrates other services' containers), since the test spans three separate Maven projects that no single repo can@Importtogether. Builds the real Docker images forsource-service,db-adapter, andfile-adapterfrom their own Dockerfiles via Testcontainers'ImageFromDockerfile— a stronger proof than an in-JVM multi-context trick, since it exercises the actual deployment artifacts, not just the source. One real bug hit and fixed: Testcontainers'KafkaContainerwrapper only advertises the host-mapped address, so every container-to-container connection (all three app containers, on this test's own Docker network) bootstrapped successfully againstkafka:9092and then failed on the very next coordinator-discovery step — the exact "Kafka advertised-listener trap" already documented ininfra/README.md, just not yet hit from inside a container before. Fixed by dropping theKafkaContainerwrapper for a plainGenericContainerconfigured likedocker-compose.yml's own proven-working listener setup, minus the host-facing listener this test never needs.
- 1.21 — Manual demo pass.
infra:docker compose up --build, submit a real intern through the browser UI, confirm it in Postgres (psql) and the CSV file. Updateinfra/README.mdwith this as a documented smoke-test recipe.
Verified 2026-07-22 (Phase 1.21, expanded scope): the demo pass itself is documented in
infra/README.md, but the phase grew into a real feature: a Targets page in the UI (ui's new nav tab) giving an operator view of both Release 1 targets, talking directly to each adapter's own new admin API (GET /interns,POST /admin/pause,POST /admin/resume,GET /admin/status— added to bothdb-adapterandfile-adapter). Pause/resume controls each adapter's own Kafka listener viaKafkaListenerEndpointRegistry, not the target container itself — a container can't pause itself (that would freeze the very HTTP thread handling the pause request), and pausing the listener proves the identical zero-data-loss guarantee: while paused, the consumer group's offset doesn't advance, so nothing published during the pause is lost, and the backlog processes once resumed. Clicking a target shows its actual data: a searchable table for Postgres, a spreadsheet-style grid (cell borders, row numbers, header shading) forinterns.csv, deliberately styled to read as a distinct "raw file" view rather than another database table.One severe bug found via a real docker-compose run (not either adapter's own test suite): both adapters'
@KafkaListenerused the identicalidstring for the new pause/resume lookup, and Spring Kafka'siddoubles as the Kafka consumer group id by default unlessgroupIdis set separately — sodb-adapterandfile-adaptersilently joined the same Kafka consumer group, and Kafka splitintern.created's partitions between them instead of each independently seeing every message, breaking the platform's core fan-out guarantee. Invisible to either adapter's own tests, since each spins up an isolated Kafka broker with only that one adapter ever connected. Caught because the Targets page's own zero-data-loss check failed in a way that didn't add up (a submitted intern never appeared in the Database view at all, not even after a long poll, while the same intern did appear in the File view) — fixed by settinggroupIdexplicitly to each adapter's already-correctapplication.ymlproperty. Full pipeline re-verified end-to-end in a real browser afterward: submit, pause Database, submit again, confirm absent while paused, resume, confirm it lands.
Release 2 — Reliability Hardening
- 2.1 — Formalize the classifier. Extract
FailureClassifier(from 1.17) into a shared, explicitly-documented exception → classification table; expand the unit test table with more exception types as they're discovered in practice. - 2.2 — Chaos test: Postgres. Formalize 1.16 into a repeatable chaos suite: kill/restart Postgres via Testcontainers mid-consumption, assert eventual consistency with no loss/duplication.
- 2.3 — Chaos test: Kafka reachability. Same for a Kafka broker disruption affecting the File Adapter.
- 2.4 — DLQ envelope completeness check. Audit the DLQ payload against the full schema in Data Model §3 (
attemptCount,failedAdapter, timestamps, etc.) and fill any gaps left from 1.19. - 2.5 — Retry/backoff externalized per-adapter. Confirm max attempts and backoff are independently tunable per adapter via config, not shared hardcoded values.
Verified 2026-07-24 (Phases 2.1–2.5): every
FailureClassifier(both adapters) was rewritten from an if/else chain into an explicitList<ClassificationRule>evaluated first-match-wins across the cause chain -- the ordering-sensitivity bug Phase 1.17 found (a narrower non-retriable type being a subtype of a broader retriable one) is now a property of the table's declared order, not a comment asking the next editor to remember it.db-adapter's test table grew from 6 to 12 cases,file-adapter's from 4 to 7, including a genuine cause-chain-walk test file-adapter was missing (parity gap withdb-adapter, unrelated to this release but fixed in passing).2.2/2.3 each got a new two-test chaos suite (
PostgresChaosTestindb-adapter,KafkaReachabilityChaosTestinfile-adapter) covering both properties 1.16 only asserted for a single message: eventual consistency with no loss (every message published during the outage still lands) and no duplication (idempotency holds even when retries and redelivery are both in play at once, including a "flapping" variant that disrupts the target more than once during a single message's retry window). Both use Docker pause/unpause, not literal kill/restart, deliberately preserving 1.16's already-hard-won finding that stop/restart of a single Testcontainers instance is unreliable -- the phase's own "kill/restart" wording is satisfied in spirit (a genuine, repeatable disruption-and-recovery cycle), not lifted literally over a technique already proven flaky.2.4's audit was the most consequential part of this release -- it started as "check the fields are all present" (they were: both
DlqEnvelopes already matched Data Model §3 exactly) and surfaced four real, previously-invisible bugs, none caught by any existing test because Phase 1.19'sDlqTestonly ever exercised one path (a malformed message, immediately non-retriable, never retried):
DlqPublisher.errorType()labeled any unrecognized exception"RETRY_EXHAUSTED"regardless of whether it had actually been retried -- an immediately non-retriable failure on attempt 1 was indistinguishable from a target that failed for the entire retry budget, defeating the envelope's own stated purpose ("the failure is fully diagnosable"). Fixed by asking the classifier first:RETRIABLE→RETRY_EXHAUSTED,NON_RETRIABLE→ a specific label or the newUNCLASSIFIED_FAILUREfallback (documented in Data Model §3).- Both adapters'
JacksonConfigObjectMappernever disabledWRITE_DATES_AS_TIMESTAMPS-- the exact gotcha already in this document's own table, just never applied to thisObjectMapper(only the Kafka producer's).quarantinedAtwas serializing as a raw epoch-second double (1.784874960562927E9), not the ISO-8601 string the data model specifies. Caught only because a new test finally parsed it as anInstantinstead of treating the envelope as an opaque string.- Proving a genuine
RETRY_EXHAUSTEDpath required a Postgres outage long enough to exhaust every attempt (not a brief 1.16-style blip), and that surfaced three distinct exception typesFailureClassifierdidn't recognize -- all real, all from the@Transactionalmachinery rather thanorg.springframework.dao, none exercised by any prior test:CannotCreateTransactionException(transaction couldn't be opened -- no connection available),TransactionSystemException(the write failed and the subsequent rollback also failed against the same broken connection -- Spring logs "Application exception overridden by rollback exception" and this is what actually propagates), and Hibernate's ownTransactionException(the same failure surfacing wrapped in Spring's genericJpaSystemExceptioninstead of being translated to either rule above). All three are now explicit, documentedRETRIABLErules with the reasoning for why each is safe to retry (none of them are about the data).- A genuine production reliability gap, not just a test artifact:
db-adapterhad no PostgreSQL driver-levelconnectTimeout/socketTimeoutconfigured at all (both default to 0 / no timeout). Against a target that's genuinely unresponsive rather than actively refusing (a paused container's kernel-level TCP stack can still ACK the initial SYN, since the accept queue is kernel-managed independent of the frozen process -- the hang is waiting for a protocol-level response only the frozen process could send), a single connection attempt was measured at 25-28 seconds, dominated by the OS's owntcp_syn_retriesbehavior rather than anything Hikari'sconnection-timeoutbounds. Fixed with explicitspring.datasource.hikari.data-source-properties.connectTimeout/socketTimeoutdefaults inapplication.yml(both externalized via env vars), which is a real hardening improvement independent of this release's test suite -- a truly unresponsive Postgres in production should fail fast into the retry/DLQ path, not hang each attempt for however long the kernel's default happens to be.
DlqEnvelopeTest(new, both adapters) proves the full envelope by parsing real JSON off the real DLQ topic (not substring checks like 1.19's test) and specifically forces theRETRY_EXHAUSTEDpath:db-adapter's version pauses Postgres for a fixed window sized to the ~27s-per-attempt reality above;file-adapter's pointsiip.file.output-pathat an existing directory, soFiles.writeStringfails with a persistent, always-retriable-per-classifierFileSystemExceptionthat never recovers on its own -- a deliberately different, simpler failure-forcing technique than Docker pause, since file I/O failures don't have the same kernel-level TCP wrinkle Postgres does.2.5 turned out to already be true at the application level (each adapter's
RetryPropertiesis@ConfigurationProperties(prefix = "iip.retry")in its own Spring context -- structurally incapable of sharing state even if someone wanted to) but was silently false at the deployment layer:infra/docker-compose.ymlnever passedRETRY_MAX_ATTEMPTS/RETRY_INITIAL_BACKOFF_MS/RETRY_MULTIPLIERinto either adapter's environment at all, so no.envoverride could ever reach them (they'd have silently usedapplication.yml's bare defaults regardless of what.envsaid). Fixed with distinctDB_ADAPTER_RETRY_*/FILE_ADAPTER_RETRY_*variable names (not one sharedRETRY_*name, which couldn't hold two different values for two services at once) -- the same class of gap independently found and fixed forCORS_ALLOWED_ORIGINSearlier in this engagement, this time caught proactively via this phase's own "confirm" wording rather than via a live bug report.
Release 3 — Contract Extraction + Envelope Split
Goal: make the existing intern pod a config-driven instance rather than a hardcoded one — the cheapest possible proof of parameterization, and the step that commits to neither Path A nor Path B (06 §4). Nothing user-visible changes in this release; that's the point.
- 3.1 — Envelope type.
source-service: add aCanonicalEnvelopetype with the fixed fields from Data Model §1a (recordId,contractId,recordType,schemaVersion,naturalKey,occurredAt,traceId,payload) and move the existing intern fields down into its payload. Two of 1.1's fields go up rather than down —recordIdandcreatedAt(asoccurredAt) were always envelope-level concerns and become envelope fields, leaving the payload as exactly the nine business fields the contract declares (Data Model §1b). Nothing is renamed, retyped, or lost. Done when: a unit test asserts the envelope round-trips through JSON, and that re-flattening it (enveloperecordId+ payload fields +occurredAtascreatedAt) reproduces 1.1's wire shape byte for byte. - 3.2 — Adapters read the envelope.
db-adapter+file-adapter: deserialize the envelope and pull the intern fields frompayloadrather than the top level, takingrecordId/occurredAtfrom the envelope. Done when: both adapters' full suites pass unchanged in assertions (only the fixture shape moves) — a real end-to-end submit still lands one Postgres row and one CSV line, and the CSV's columns are byte-for-byte what they were.
Verified 2026-07-27 (Phases 3.1–3.2): all three suites green against real Testcontainers infrastructure —
source-service31/31 (was 23),db-adapter20/20,file-adapter24/24 — with no existing assertion weakened or deleted to accommodate the new shape. The five tests added are all in the newCanonicalEnvelopeTest; the three added toCanonicalMapperTestcover envelope fields that didn't exist before (contract identity, key derivation, unsettraceId).A doc conflict surfaced and was resolved in favour of the data model. 3.1's original done-when asked for the payload's JSON to be "byte-identical to what 1.1's record produced," which cannot hold alongside Data Model §1b: the interns payload is exactly nine business fields, and
recordId/createdAtare envelope-level. Rather than duplicate those two fields on both levels — two sources of truth, guaranteed to drift — the payload follows §1b and the done-when was rewritten to the check that actually proves nothing was lost:reflatteningReproducesTheReleaseOneWireShapeExactlyreconstructs the flat Release 1 JSON fromrecordId+ payload +occurredAtand asserts it byte for byte. That test is the one that would catch a renamed or dropped field; the original wording would not have, since it never compared against the old shape at all.Two decisions worth recording.
InternsContractholds the three hardcoded values (contractId,recordType,schemaVersion) in one named, explicitly-temporary class rather than as literals scattered throughCanonicalMapper— Phase 3.3 deletes it wholesale instead of hunting string literals.EnvelopeJsonFixture(one per adapter, test scope) exists because four separate adapter tests had each inlined their own copy of the flat wire shape; without it, 3.2 would have meant editing the same JSON literal four times per adapter, and Releases 5–7 would each mean editing it four more. The fixture moved; every assertion around it stayed.The
GET /internsHTTP response shape is deliberately unchanged —InternSummaryResponsestill flattensrecordId+ payload +createdAt— souineeded no changes and the wire-contract split stays invisible to clients.infra/e2e-testslikewise needed no changes: it drives the realPOST /internsintake shape (untouched) and asserts on Postgres rows and CSV lines (untouched), which is precisely the property 3.2 claims.One process note, since it cost a full re-run: running
db-adapterandfile-adaptersuites concurrently made a Kafka Testcontainer exit 1 with "Timed out waiting for log output matching.*Transitioning from RECOVERY to RUNNING.*" — a resource-contention failure with no test code involved, which is easy to misread as a real regression. Run the adapter suites serially.
- 3.3 — Contract file.
source-service: add theinternscontract file from Data Model §1c to the image and aContractLoaderthat reads it at boot. Done when: the app fails fast at startup with a clear error if the file is missing or malformed, and logs the loadedcontractId+schemaVersionon success. - 3.4 — Contract-driven validation.
source-service:PayloadValidatorvalidates the submitted payload against the loaded contract — field presence, types, enum domain. Test-first. Done when: removing a required field from the contract file makes a previously-valid submission return400, with no recompilation — this is the phase that actually proves parameterization, so don't let it pass on a mock. - 3.5 — Contract-driven key + mapping; delete the compiled schema.
source-service: derivenaturalKeyvia the contract's key strategy, then deleteCanonicalInternRecord,CanonicalMapper, andCreateInternRequest's Bean Validation annotations. Done when: the compiled intern schema no longer exists in the codebase — not merely bypassed (Implementation Plan §7 risk register: a fallback that still compiles is a fallback that will be used). - 3.6 — Topic rename to the derived scheme. All services +
infra:intern.created→interns.created,intern.dlq→iip.dlq, topic names derived fromcontractIdrather than hardcoded (Architecture §10). Rename only — the partition key's runtime value is unchanged, sincenaturalKeyforinternsisinternId. Done when:docker compose upon a clean volume produces the new topic names and the full e2e test passes against them. - 3.7 — Regression gate: the intern pod behaves identically. Re-run every Release 1–2 suite plus
infra/e2e-testsagainst real Docker images, and a manual browser submit. Done when: all green with no test weakened to accommodate the change. ← parameterization proven. - 3.8 — Forms as contract #2. Add a
formscontract file (no Java changes at all) and submit a forms record. Done when: it validates, publishes toforms.created, and the only diff in the repo is one new file. If any Java change was needed, 3.4/3.5 aren't actually finished — go back rather than patching forward.
Verified 2026-07-27 (Phases 3.3–3.8): every automated gate green —
source-service48/48 (was 31),db-adapter32/32,file-adapter32/32, andinfra/e2e-tests1/1 against real Docker images rebuilt from this source. No test was weakened or deleted to accommodate the change. Ten test methods were removed, but only because the classes they tested no longer exist (3.5 deletedCanonicalInternRecord,CanonicalMapper,CreateInternRequest,InternRecordStore); every behaviour they asserted is now asserted against the contract-driven path instead, and the net test count went up in all three services.Two gaps found on a second pass, after the phases were first called done. Both were cases where a done-when was asserted rather than tested, which is the failure mode this document exists to prevent, so they're recorded rather than quietly fixed:
- 3.3's fail-fast claim was untested.
ContractLoaderTesthad seven tests, but five of them constructedContract/FieldDefinition/FieldTypedirectly and never touchedContractLoader— they tested the model's invariants, which says nothing about whether a broken file ever reaches them. The loader's own parse path (ninethrowsites) and its missing-file path were entirely unexercised, while the done-when specifically says "missing or malformed". Fixed by making the scan location injectable and adding six tests over real broken YAML fixtures, asserting each error names the file and the offending token.- 3.8's "publishes to
forms.created" rested on a mock. A Mockito captor asserting which topic name was passed to a mockedKafkaTemplateis a weaker claim than the phase makes. Fixed by addingaSecondContractsRecordTravelsTheWholePathToItsOwnTopic, which validates → builds → publishes a forms record and reads it back off a real broker, asserting the composite keyF-IT-1|Q-IT-9and both declared defaults survive the round trip.What 3.8 actually cost. One file in
src/main—contracts/forms.yaml. NoFormsController, noFormPayload, no forms mapper, no forms topic declaration, noif (contractId.equals("forms")). (Tests were added, of course; the phase's "only one new file" is a claim about production code, and that is how it should be read.) The forms contract deliberately exercises what interns cannot — a composite natural key ([formId, questionId]→"F-1|Q-7"), an integer, and a boolean — because a second contract shaped like the first would still pass against code that secretly assumed a single string key.KafkaTopicsConfigTestassertsforms.createdis provisioned as a real Kafka topic, which is the infrastructure half of the same proof.3.4's done-when was taken literally.
makingAFieldOptionalInTheContractChangesValidationWithNoRecompileloads two contract files differing only in onerequired:flag and asserts the same payload is rejected by one and accepted by the other — no mock, no stub validator. That test is the whole point of the release; if it ever starts passing for the wrong reason, parameterization is gone.One extension beyond Data Model §1c, now folded back into it:
default. Release 1'sCanonicalMapperhardcoded "a new intern always startsACTIVE", and the UI has never sentstatus. Deleting that mapper in 3.5 left a choice between makingstatusoptional (weakening the contract) and making every client send it (changing the intake shape). Neither is acceptable for a release whose whole claim is "behaves identically", so the field model grew adefault:applied before the required check. §1c now documents it.A second, smaller extension: unknown fields are rejected, not ignored. A payload key the contract doesn't declare is almost always a typo or a client running ahead of the schema, and silently dropping it would hide data loss behind a
202. It also keeps the generic landing table (Data Model §4.0) from storing the typo forever.One intern-shaped class survives 3.5 on purpose:
InternSummaryResponse, theGET /internsread model, still names the nine business fields. It is outside 3.5's stated scope (which namesCanonicalInternRecord,CanonicalMapper, andCreateInternRequest's annotations) and deliberately so — keeping the read shape frozen is what letsuigo untouched through the whole release. It generalizes in Phase 4.5 alongsideGET /contracts/{id}/records. Nothing on the write path knows what an intern is.As built vs as designed, on topics. Architecture §10 derives topics from
contractId; the code now does exactly that (TopicNames.created(contractId)), andapplication.ymlno longer carries a topic name at all — only partition counts, which are deployment-shaped rather than schema-shaped. The DLQ went the other way on purpose:iip.dlqis platform-wide rather than per contract, because it is an operator surface and every DLQ envelope already carries thecontractIdto filter on.3.7's manual sign-off, 2026-07-29. The clean-volume run (
docker compose down -v && docker compose up -d --build) and the UI submit were both done by hand, and the stack afterwards shows what the release claims. Kafka lists exactlyinterns.created,forms.created, andiip.dlq, with nointern.createdsurviving anywhere. Two interns submitted through the UI (INT001,INT002) landed in Postgres and ininterns.csvwith byte-identicalrecord_ids, which is the envelope's identity holding across two independently-written adapters.iip.dlqsat at offset0throughout — nothing dead-lettered. Both rows readstatus=ACTIVEalthough the UI has never sentstatus, so thedefault:that replacedCanonicalMapperworks in the real stack and not just in the validator's tests. Andforms.createdexists on a broker in a deployment where nothing has ever submitted a form:KafkaAdminprovisioned it fromforms.yamlalone, which is 3.8's claim confirmed by infrastructure rather than by a test.Two environment snags worth recording, neither a code fault. The
uiservice builds from../ui, a separate repository (Azaken1248/iip-ui) that has to be cloned as a sibling ofinfrabeforedocker compose upcan succeed — a fresh checkout of the backend repos alone fails the build withpath "/home/aza/IIP/ui" not found, after the three Java images have already built. And the UI's default host port3000is a popular one; when it is taken, either free it or setUI_HOST_PORTand add the new origin toCORS_ALLOWED_ORIGINS, sincesource-servicewill otherwise serve the page and then reject every submit it makes.One process note carried over from 3.1–3.2, now understood properly: the Kafka Testcontainer failure "Timed out waiting for log output matching
.*Transitioning from RECOVERY to RUNNING.*" is memory pressure, not a code fault. It reappeared here and was fixed by capping the fork heap (MAVEN_OPTS=-Xmx512m) and running the adapter suites serially. It involves no test code at all and is easy to misread as a real regression.
Release 4 — Parameterized Source Service + Contract Registry
Goal: the contract stops being a file in an image and becomes data in a service, and the envelope becomes a registry-enforced contract. Absorbs the former "Contract Enforcement" release.
- 4.1 — Contract Registry schema.
infra: thecontractsandadapter_attachmentstables from Data Model §1c, with an init script mirroring the pattern already used forpostgres/init.sql. - 4.2 — Contract Registry service skeleton. New repo/service: Spring Boot + JPA over those tables,
ddl-auto: validate, Actuator health. Done when: it starts against a real Postgres via Testcontainers and/actuator/healthis up. - 4.3 — Contracts API.
GET /contracts,GET /contracts/{id},POST /contracts. Test-first: posting theinternscontract from 3.3 and reading it back yields an identical definition. - 4.4 — Source-service reads from the registry. Replace the baked-in file with a registry fetch + cache and a refresh interval. Done when: the file is deleted and the pod still works; a contract inserted while the service is running is picked up within one refresh interval.
- 4.5 — Multi-contract routing in one instance.
POST /contracts/{id}/recordsroutes by path, validating against whichever contract was named. Done when: one running instance accepts aninternsand aformssubmission back-to-back and each is validated against its own definition (a forms payload posted asinternsreturns400). - 4.6 — Schema Registry in infra.
infra: add Confluent/compatible Schema Registry todocker-compose.yml. - 4.7 — Envelope as a versioned artifact. Define the envelope schema (Avro or JSON Schema) matching Data Model §1a — note this is one schema for the whole platform, not one per contract; the payload is a free-form object at this level.
- 4.8 — Schema-validating producer + consumers.
source-servicepublishes through a validating serializer (fails fast, before Kafka); both adapters deserialize through a validating deserializer. Test: a deliberately non-conforming envelope is rejected at the boundary, not deep in business logic. - 4.9 — Per-contract compatibility check at the API.
POST/PUT /contractsruns a BACKWARD compatibility check against the stored definition and bumpsschemaVersion(Data Model §5.2). Test-first: adding an optional field is accepted; removing a required one and changing the natural-key strategy are both rejected with a clear reason. - 4.10 — CI compatibility gate. A script that runs the envelope check and replays 4.9's check over every registered contract; a deliberately-breaking change proves the gate actually blocks a merge.
- 4.11 — Backward-compatible field addition, proven. Add one optional field to the
internscontract via the API, redeploy nothing, and confirm the un-restarted adapters still process new messages correctly.
Decision recorded during 4.6–4.8 — the Schema Registry owns the schema, not the wire. The phases ask for a validating serializer and validating deserializers. Confluent's own serializers would supply that by framing every message with a magic byte and a schema id, which is the canonical implementation and was rejected here. Framing would make every adapter's bytes depend on the registry being reachable and on a Confluent client library, undoing the property held since Phase 1.12: an adapter reads canonical JSON off a topic and needs no shared artifact to understand it. What was built instead keeps plain JSON on the wire and puts the registry where the value is —
infra/schemas/envelope.jsonis registered as subjectiip.envelope-valueunder BACKWARD compatibility, all three services fetch it at startup and refuse to start without it, and every message is checked against it on the way out and on the way in. The governance process in Data Model §5 is unaffected: the compatibility check is still the registry's, and CI still runs it (infra/scripts/compatibility-gate.sh).A real bug 4.11 caught, which is what the phase is for. Both adapters rejected the added optional field outright: Jackson enables
FAIL_ON_UNKNOWN_PROPERTIESby default, so the hand-builtObjectMapperin each adapter'sJacksonConfigrefused any payload key its record did not declare. The whole control plane would have said yes — registry, compatibility gate, source service — and the change would have surfaced as one DLQ entry per record from services nobody had redeployed. Fixed by disabling the feature in both adapters, with a regression test in each; written up as producer strict, consumer tolerant in Data Model §5, since the source service's opposite behaviour is correct for the source service.Also worth knowing.
additionalPropertiesis deliberately left open on the envelope schema — BACKWARD exists so an adapter keeps working when the source service ships an envelope change first, and a closed schema would break exactly that case on the consumer side, in production. Each service keeps a copy ofenvelope.jsonundersrc/test/resourcesso its own suite runs without a registry; those copies are fixtures, and the CI gate fails if one has drifted, because a drifted fixture makes a suite that passes against a schema production does not use.
Release 5 — Config-Driven DB Adapter + Generic Landing Table
Goal: an arbitrary payload reaches Postgres with no redeploy and no DDL grant held by any service.
- 5.1 —
recordstable.infra: the hybrid landing table from Data Model §4.0, withrecord_idPK andUNIQUE (contract_id, natural_key). - 5.2 — Attachment read path.
db-adapter: readadapter_attachmentsforadapter_type = 'postgres'at boot + on a refresh interval; log the contracts it is attached to. - 5.3 — Contract filter. Skip-and-commit any envelope whose
contractIdisn't attached. Test-first: publish an unattached contract's record, assert no row written and the next attached record still processes (the partition must keep moving — this is the same liveness property as the DLQ, applied to a non-failure). - 5.4 — Generic write path. Insert envelope columns +
payloadJSONB withON CONFLICT (record_id) DO NOTHING. Test-first: "deliver twice, assert one row" — for a contract with no Java type anywhere in the repo. - 5.5 — Natural-key upsert.
ON CONFLICT (contract_id, natural_key) DO UPDATEfor update-style record types. (Lifecycle events themselves are Release 7; this phase just makes the write path ready and tests it by publishing an update-style envelope directly.) - 5.6 — Queryable fields become indexes. Fields a contract marks
queryable: trueget expression indexes overpayload, created when the attachment is registered. Done when:EXPLAINshows the index used for apayload->>'status'filter oncontract_id = 'interns'. - 5.7 — Shaped-table mode. Support a per-attachment "shaped table" config and point the existing
internstable at it. Done when: both modes run side by side —internsin shaped mode,formsinrecords— proving the generic table is a default rather than a mandate. - 5.8 — Forms lands end-to-end. Release exit criterion. Submit a forms record through the real HTTP path against real Docker images; assert it lands in
records, with zero code written for forms specifically.
Decision recorded during 5.3 — the adapter subscribes to a topic pattern, not a list. A contract registered through the control plane gets a topic (
{contractId}.created) that no deployment descriptor mentions, so a listener bound to named topics would need reconfiguring to see it — which would make UC-14's "no redeploy" false in the one place it has to be true. The adapter consumes every create stream and skips the contracts it isn't attached to. This forced one non-obvious setting: a pattern subscription cannot auto-create a missing topic the way an explicit one can, so a consumer that starts before its topics exist is assigned zero partitions and stays that way until the next metadata refresh — five minutes, by default, of a newly attached contract appearing not to work.metadata.max.age.msis 5s in both adapters, because that interval is the latency of "attach a contract and watch it flow".Decision recorded during 5.5 — no guard against a stale
occurredAtoverwriting a newer one. The natural-key upsert replaces the row wholesale, so an out-of-order update would lose data. It cannot arrive out of order: every event for one entity is keyed bynaturalKeyand therefore shares a partition, which is precisely why Architecture §5 chose that key. A timestamp guard would not add ordering, only mask a broken key strategy by turning "records are arriving out of order" into "some updates silently do not apply". Left out deliberately, recorded here so it reads as a decision rather than an oversight.The DDL question the release goal raises, and how 5.6 answers it. The goal above says "no DDL grant held by any service", and 5.6 creates indexes at runtime. The distinction being drawn is the one Data Model §4.0 drew when it rejected DDL-per-contract: a service that owns table shape owns schema drift,
ALTER TABLEmigrations, and the ability to destroy data by getting one wrong.CREATE INDEXon one existing table changes no data, loses nothing if it is wrong, and is undone by dropping it. The adapter issues that and nothing else — neverCREATE,ALTERorDROP TABLE— and index creation is never fatal: a deployment that withholds DDL rights entirely is a legitimate choice, so a permission error logs the exact SQL for an operator and the adapter carries on writing records.What 5.7 removed, which is the release in one line.
CanonicalEnvelope,InternPayload, andInternRepository's upsert are gone. The write path used to bind every message to a Java record whose fields were one contract's payload; it now works from the parsed tree in both modes, and the shaped mapping that named nine intern columns is a map inadapter_attachments.config. The adapter'ssrc/mainno longer contains a type for any contract's payload — asserted directly by a test that greps it, since a passing write test proves forms records land, not that they land generically. What stayed intern-shaped is the read side (GET /interns, which the UI calls); that generalizes in 6.9.Attachments are seeded with SQL for exactly one release. Contracts are POSTed through the public API by
contract-registry-init, because that API exists. Attachments have no API until 6.6, soinfra/seed/attachments.sqlis applied by anattachment-initjob. Deliberately not aninitdb.dscript: an attachment references a contract by foreign key and the contracts are registered over HTTP long after initdb finishes, so as an init script it would take the Postgres container down on a fresh volume — and init scripts don't run on an existing one, which is the trap Release 4 already fell into once.
Release 6 — Adapter Catalog + Attachments + Control-Plane UI
Goal: the user-facing goal is met — define a schema and wire its targets from a UI, no developer, no redeploy.
- 6.1 — Adapter type catalog. Define how a type declares itself and the config schema an attachment must satisfy (start simple — a static descriptor per adapter is fine — before reaching for anything heavier).
- 6.2 — Extract the generic adapter pattern. Refactor
db-adapterandfile-adapterto visibly share one shape (contract filter, mapping resolver, idempotency gate, classifier, retry/DLQ) — the refactor-after-duplication moment named in Implementation Plan §1, done once two real implementations exist, not designed upfront. - 6.3 —
csvadapter goes config-driven. File path and column list come from the attachment, notapplication.yml. Done when: two contracts attached to one adapter instance write two different files with two different column sets, without either interfering with the other's dedup store. - 6.4 — Pluggable formatter strategy. Extract a CSV/JSON/XML formatter interface, selected per attachment.
- 6.5 — A genuinely new adapter type:
webhook. Build it as its own repo on 6.2's shape, with zero changes toui/source-service/existing adapters — the concrete proof of UC-9. Done when: it can be attached to an existing contract and starts delivering without touching that contract's definition. - 6.6 — Control-plane adapter endpoints.
POST /contracts/{id}/adapters, list, and theenabledtoggle (UC-12). Test: disabling one contract's attachment leaves another contract's fan-out untouched. - 6.7 — UI: define a contract.
ui: the contract form (fields, types, required, queryable, key strategy, record types) with client-side validation, posting toPOST /contracts. Done when: UC-13 runs end-to-end in a real browser and a record against the brand-new contract is accepted — with no service restarted. - 6.8 — UI: attach an adapter.
ui: pick a type, fill its declared config, post the attachment. Done when: UC-14 runs end-to-end in a browser and the target starts receiving records. - 6.9 — UI: contract-driven record form + records view. The submission form and records table render from the contract's field definitions rather than a hardcoded intern layout. Done when: the forms contract gets a usable form with no UI code written for it.
- 6.10 — Shared adapter test harness. A base suite ("every adapter type must pass idempotency + DLQ + contract-filter tests") applied to all three types, including the new one.
Decision recorded during 6.1 — adapters register themselves. The catalog could have been a list in the registry, and UC-9 would still have been satisfied to the letter, since it names the UI, the source service and the existing adapters but not the registry. It would also have made 6.5 a lie in spirit: adding a type would mean editing a second repository, and that entry would be the first thing to go stale. So each adapter ships a static descriptor next to the code that reads the config it describes, and PUTs it at startup. A type exists because its service is running.
What the descriptor turned out to be worth. It started as documentation for a dropdown and became enforcement: 6.6 validates an attachment against the config schema its adapter published, so a webhook with no
urlis refused at attach time with the type's own description of the field quoted back — instead of surfacing one record at a time in the DLQ, with the adapter that could have explained it nowhere near the request that caused it.Decision recorded during 6.2 — the shape is duplicated, not shared. "Extract the generic adapter pattern" reads like a library, and the docs point elsewhere: Architecture §6 calls its pipeline the "acceptance checklist for any new adapter", and 6.5 builds one in its own repo. This platform has no artifact repository, and a published jar would turn "a new adapter type is a new service" into "a new service plus a versioned dependency on ours". So every adapter carries the pipeline, byte-identical modulo package name, and two gate checks keep the copies honest — one comparing the classes character by character, one comparing the acceptance suite that tests them. The second matters more: a weakened copy of the checklist is worse than a drifted pipeline, because it is the thing that would have caught the drifted pipeline.
Three bugs that only running it could find, all in code that compiled and passed.
jsonbdoes not preserve an object's key order — it sorts keys by length then bytewise — so the csv adapter's column mapping came back rearranged and it wrote rows that did not match the header above them; the mapping is an ordered array now, and the fixtures are shaped like something a deployment can actually produce.iip.file.dedup-store-pathchanged meaning from a file to a directory, and an existing deployment had a file exactly where the new version wanted a directory: it started cleanly and then sent every record to the DLQ with "Not a directory", so it migrates itself and carries the old ids over. AndisContainerPaused()is true only once every assigned partition is paused, so a listener paused before its topics existed — the ordinary state of a newly attached contract — reported itself running forever.The window nobody would guess. Between attaching a target and the adapter's next refresh, records are skipped by the contract filter and their offsets committed; they are not delivered to the new target afterwards. That is correct skip-and-commit behaviour and a genuinely surprising consequence, so the attach form says so and points at replay. Which in turn surfaced that Kafka had no volume at all —
docker compose downdestroyed every topic, taking both replay and the DLQ with it.
Release 7 — Generalized Lifecycle
Goal: the former "Mutable Lifecycle" release, re-expressed so it works for every contract at once.
- 7.1 — Record-type classes in the contract. Let a contract declare which of its record types are update-style and which are tombstones (Data Model §2); validate the declaration at registration time.
- 7.2 — Lifecycle topics per contract. Provision
{contractId}.updated/.deletedwhen a contract declares them — and not when it doesn't. A create-only contract must remain a legitimate, simpler contract. - 7.3 —
PUT /contracts/{id}/records/{naturalKey}.source-service+ui: edit endpoint publishing an update-style envelope; wire the UI's Edit action. Test: a contract with no update-style type returns409. - 7.4 —
DELETE /contracts/{id}/records/{naturalKey}. Same, publishing a tombstone. - 7.5 —
postgresadapter handles update-style events. Upsert on the contract's declared natural key (5.5's write path, now driven by real events) in both generic and shaped modes. - 7.6 —
postgresadapter handles tombstones. Delete/soft-delete the row per the attachment's retention config. - 7.7 — Audit log. An append-only audit row per event, independent of the current-state row, carrying
contractId/recordId/traceId(Data Model §4.1). - 7.8 —
csvadapter: keyed state store. Replace the seen-set dedup store with a keyed(contractId, naturalKey) → current record (or tombstone)store. - 7.9 —
csvadapter: rebuild-from-state. Rebuild each contract's CSV from the keyed store on every event, replacing pure append (Data Model §4.2). - 7.10 — Ordering test across two contracts. Interleave create/update/delete rapidly for one key; assert final state in Postgres and the CSV. Run it for two contracts, one of them with a composite natural key — a single-field key would not exercise the key-derivation path that generalization actually introduced.
Release 8 — Observability + Operability
Goal: the former Observability and Operability releases, now multi-contract aware. contractId as a metric dimension is what keeps this one dashboard instead of one per schema.
- 8.1 — Metrics endpoint. All services: add
micrometer-registry-prometheus, expose/actuator/prometheus. - 8.2 — Prometheus in infra. Scrape every service.
- 8.3 — Grafana in infra. Provisioned Prometheus datasource.
- 8.4 —
contractIdas a metric dimension. Tag adapter success/failure/throughput counters withcontractId(bounded cardinality — contracts are a small, registry-controlled set, unlike record ids). Done when: a per-contract breakdown is queryable in Prometheus before any panel is built on it. - 8.5 — Lag panel. Consumer lag per adapter over time.
- 8.6 — DLQ depth panel. DLQ depth over time, broken down by
contractId; a visual threshold (or alert) on sustained non-zero depth. - 8.7 — Throughput/error panel. Per-adapter and per-contract success/failure counts and throughput.
- 8.8 — traceId propagation. Generate a
traceIdat intake; carry it in the envelope and every log line at every hop, alongsiderecordIdandcontractId(structured logging in all services). Done when: a documented "search all logs for one traceId" walkthrough actually reconstructs one submission's full journey. (This also closes the Definition-of-Done gap first recorded in the 1.1–1.8 review.) - 8.9 — DLQ read API. Non-destructive read of
iip.dlq, grouped by contract / error type / adapter. - 8.10 — Admin dashboard: DLQ viewer. UI page rendering 8.9's data, filterable by contract.
- 8.11 — DLQ replay tool. Re-publish a selected message to the topic derived from its own envelope — not a configured destination; mark the entry replayed (audit trail, not deleted).
- 8.12 — Admin dashboard: operational overview. Aggregate health/lag/DLQ depth into one view.
Release 9 — Path A: Per-Contract Instances (optional, gated)
Do not start this release unless hard isolation has become a real, stated requirement (AD-12). It is broken down here only so the option stays cheap and its cost stays visible. The user-facing outcome is identical to Path B.
- 9.1 —
IIPInstanceCRD. Define the custom resource:contractId, adapter attachments, resource limits. - 9.2 — Operator reconcile loop. Reconcile a CR into topics, a configured source-service pod, and adapter pods from the catalog images.
- 9.3 — Prove the images are unchanged. Run a contract under Path A using the same image digests Path B runs. Done when: the diff between the two deployments is config and namespace only. If a code change was needed, AD-9's parameterization bet did not actually pay off, and that finding is worth more than the release.
Keeping this document honest
- When a phase's actual implementation reveals the next one needs re-splitting or reordering, edit this file — it's a working checklist, not a frozen spec.
- Every phase should map to one commit (or a small handful) in the relevant repo. If a phase is producing multi-day, multi-file sprawl, it wasn't tiny enough — split it before continuing, per the project's small-releases practice.
- Re-check Implementation Plan §6 Definition of Done at each phase, not just at the end of a release.
- From Release 3 onward, one extra check per phase: would this work for a second contract? If the answer is no, the phase isn't done — the point of the generalization track is that "add a branch for the new schema" stops being an available move. See Implementation Plan §5.