diff --git a/.github/workflows/rc-adopter-gate.yml b/.github/workflows/rc-adopter-gate.yml new file mode 100644 index 0000000..c076863 --- /dev/null +++ b/.github/workflows/rc-adopter-gate.yml @@ -0,0 +1,44 @@ +name: RC Adopter Gate + +on: + workflow_dispatch: + inputs: + version: + description: Published Codes RC version + required: true + default: 0.4.0-RC1 + +permissions: + contents: read + +jobs: + adopters: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: v${{ inputs.version }} + persist-credentials: false + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: "21" + + - name: Verify external adopter gate + run: >- + bash scripts/verify-rc-adopters.sh + "${{ inputs.version }}" + build/reports/rc1-adopters.md + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: rc1-adopter-gate-manual + path: | + build/reports/rc1-adopters.md + build/rc-adopters/*.log + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..b17515c --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,180 @@ +name: Release Candidate + +on: + push: + tags: + - "v*.*.*-RC*" + +permissions: + contents: read + +concurrency: + group: maven-central-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 90 + outputs: + version: ${{ steps.version.outputs.value }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + + - id: version + name: Verify release-candidate tag + shell: bash + run: | + set -euo pipefail + + version="$(sh ./scripts/version.sh)" + + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-RC[1-9][0-9]*$ ]] || { + echo "Invalid release-candidate version: $version" + exit 1 + } + + test "${GITHUB_REF_NAME}" = "v${version}" + + git fetch --no-tags origin main + + test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \ + "$(git rev-parse "origin/main^{commit}")" + + echo "value=$version" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-provider: basic + + - name: Verify release-candidate tree + run: | + python scripts/verify-compatibility-matrix.py + ./gradlew clean verifyAll --stacktrace + + - name: Publish clean release-candidate artifacts + run: bash scripts/prepare-compatibility-repository.sh build/compatibility-maven + + - name: Verify published-artifact consumers before Central + shell: bash + env: + MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven + run: | + set -euo pipefail + version="$(sh ./scripts/version.sh)" + + ./gradlew \ + -p smoke-test-java \ + "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ + clean check \ + --stacktrace + + ./gradlew \ + -p smoke-test-kotlin \ + "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ + clean check \ + --stacktrace + + mvn --batch-mode --no-transfer-progress \ + "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ + -f smoke-test-maven-java/pom.xml \ + "-Dcodes.version=$version" \ + clean verify + + bash ./scripts/verify-kotlin-nullability.sh 2.4.10 + + - name: Publish all Codes artifacts to Maven Central + run: >- + ./gradlew + :publishToMavenCentral + :codes-spring:publishToMavenCentral + :codes-grpc-java:publishToMavenCentral + --no-configuration-cache + --stacktrace + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + + real-adopters: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: "21" + + - name: Verify two external adopters from Maven Central + run: >- + bash scripts/verify-rc-adopters.sh + "${{ needs.publish.outputs.version }}" + build/reports/rc1-adopters.md + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: rc1-adopter-gate + path: | + build/reports/rc1-adopters.md + build/rc-adopters/*.log + if-no-files-found: warn + retention-days: 30 + + github-prerelease: + needs: [publish, real-adopters] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Create GitHub prerelease + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + version="${GITHUB_REF_NAME#v}" + + awk -v version="$version" ' + $0 == "## " version { + capture = 1 + next + } + capture && /^## / { + exit + } + capture { + print + } + ' CHANGELOG.md > release-notes.md + + test -s release-notes.md + + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --prerelease \ + --title "Codes $version" \ + --notes-file release-notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd2b7f1..dbfdf4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" + - "!v*.*.*-RC*" permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc4d4b..95d4f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ ## Unreleased +## 0.4.0-RC1 + ### Added * Added Maven publications for `codes-spring` and `codes-grpc-java`. * Added adapter API snapshots and exact published dependency budgets. -* Added clean Gradle and Maven consumer checks that resolve all three artifacts from Maven Local. +* Added clean Gradle and Maven consumer checks that resolve all three artifacts from isolated publication repositories. * Added application-owned Spring problem-type URI mappings. * Added a thin failed-`Outcome` to Spring `ErrorResponseException` bridge. * Added Spring RFC 9457 golden response contracts plus MVC and WebFlux compatibility checks. @@ -15,10 +17,13 @@ * Added a production compatibility matrix for Spring 6/7, minimum/current gRPC, Java 17/21/25, Java/Kotlin consumers, Gradle/Maven, and Linux/Windows/macOS. * Added adapter JSpecify consumer verification and adapter coverage gates. * Added published-POM verification against clean isolated Maven repositories. +* Added ten-minute Spring and gRPC boundary examples. +* Added a reproducible release-candidate adopter gate against two pinned external codebases. +* Added a dedicated release-candidate workflow that publishes to Maven Central before running the external adopter gate. ### Changed -* Aligned all publishable modules on the shared `0.4.0-SNAPSHOT` version. +* Set all publishable modules to `0.4.0-RC1`. * Corrected Spring problem details so reusable outcome messages are titles for explicitly mapped problem types and occurrence details use RFC `detail`. * Kept stable Codes identity in the Spring `code` extension for every mapped failure. * Reworked the Spring orders reference to consume `codes-spring` instead of duplicating adapter behavior. @@ -26,6 +31,7 @@ * Preserved exposed structured issues through `google.rpc.BadRequest` without normalizing coded issue identity. * Reworked the gRPC orders reference to consume `codes-grpc-java` instead of constructing rich error details manually. * Moved publication consumer verification to freshly emptied Maven repositories so compatibility checks cannot be satisfied by stale local artifacts. +* Documented cases where Codes should not be introduced. ## 0.3.1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5eb9912 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,48 @@ +# Code of Conduct + +Keep project interaction technical, professional, and useful. + +## Expected behavior + +- Criticize code, design, tests, or reasoning. Do not attack the person. +- Keep issues, reviews, and discussions on topic. +- Back technical claims with evidence when possible. +- Accept review feedback and rejected proposals without making it personal. +- Respect privacy. + +Strong disagreement is fine. + +Harassment is not. + +## Unacceptable behavior + +The following is not accepted: + +- harassment or discrimination +- threats or intimidation +- personal attacks +- trolling or deliberate provocation +- publishing private information +- sustained bad-faith disruption +- spam, including automated or AI-generated issue and pull-request spam +- abusive language directed at contributors or maintainers + +Technical criticism is not a Code of Conduct violation merely because it is direct. + +Keep it about the work. + +## Enforcement + +The maintainer may remove content, close issues or pull requests, restrict participation, or temporarily or permanently block contributors. + +Serious abuse does not require a warning before action is taken. + +## Reporting conduct problems + +If a report contains harassment, threats, private information, or anything else that should not be public, contact the maintainer privately using the contact information on the maintainer's GitHub profile. + +Do not create a public issue containing sensitive personal information. + +## One rule + +Be useful. Be respectful. Keep it about the work. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3506400..51eccdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,97 @@ # Contributing -## Before changing semantics +Keep changes focused, tested, and justified. -Changes to standard outcome membership, standard outcome state, or a built-in HTTP/gRPC mapping change the semantic contract. Update the relevant fixture under `compatibility/` in the same pull request and explain why the semantic change is required. +## Before changing contracts + +Codes has public contracts across three artifacts: + +- `codes` +- `codes-spring` +- `codes-grpc-java` + +Changes to standard outcome membership, standard outcome state, or built-in HTTP/gRPC mappings change the semantic contract. + +Public API changes require updating the matching snapshot under `api/`. + +Spring or gRPC wire changes require updating the matching snapshot under `compatibility/`. + +Do not update a snapshot just because a test failed. Understand the change and explain why the contract should move. + +The core artifact must remain free of runtime dependencies. + +Adapters must stay thin and explicit. Do not introduce auto-configuration, serialization frameworks, registries, hidden mapping rules, or unrelated abstractions as part of an adapter change. ## Build +Run the full repository gate. + +Windows: + +```powershell +.\gradlew.bat clean verifyAll --stacktrace +``` + +Linux/macOS: + ```bash -./gradlew clean verifyAll -./gradlew :publishToMavenLocal :codes-spring:publishToMavenLocal :codes-grpc-java:publishToMavenLocal -./scripts/verify-local-publications.sh -./gradlew -p smoke-test-java clean check -./gradlew -p smoke-test-kotlin clean check +./gradlew clean verifyAll --stacktrace ``` -Maven consumer checks: +For publication or dependency changes, also verify all three artifacts from a clean isolated Maven repository. + +Windows: + +```powershell +.\scripts\prepare-compatibility-repository.ps1 ` + -Repository .\build\compatibility-maven +``` + +Linux/macOS: ```bash -version=$(./scripts/version.sh) -mvn -f smoke-test-maven-java/pom.xml -Dcodes.version="$version" clean verify -./scripts/verify-kotlin-nullability.sh 2.4.10 +bash scripts/prepare-compatibility-repository.sh build/compatibility-maven ``` +The CI compatibility workflow covers the supported Java, Kotlin, Spring, gRPC, Gradle/Maven consumer, and operating-system matrix. + ## Pull requests -Keep changes focused. Include tests for behavior changes. Public API changes require updating the matching snapshot under `api/`; semantic changes require updating the matching compatibility snapshot. +Keep pull requests small enough to review. + +Include tests for behavior changes. + +Explain any: + +- public API change +- semantic or wire change +- dependency change +- compatibility change + +Do not mix unrelated cleanup with a behavioral change. + +Do not weaken a compatibility, coverage, dependency, or publication check just to make CI green. + +## AI-assisted contributions + +AI-generated work without human understanding is **not** allowed. + +Contributions require a human who reviews, understands, and takes responsibility for the work. + +If AI was used, the contributor must: + +- understand every submitted change +- verify factual and compatibility claims +- run the relevant tests +- be able to explain the design and tradeoffs +- remove generated code that is unnecessary or outside scope +- take responsibility for regressions + +The following may be rejected without detailed review: -Do not add framework dependencies to the core artifact. +- unreviewed generated code +- prompt dumps +- fabricated tests, benchmarks, or compatibility claims +- large generated rewrites without a concrete reason +- generated issue or pull-request text containing claims the contributor did not verify +- "the AI said it works" as technical justification diff --git a/README.md b/README.md index e7afe29..3f855a8 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,38 @@ com.example.payments:PAYMENT_DECLINED ## Install -Gradle: +`0.4.0-RC1` is a release candidate. + +Core: ```kotlin dependencies { - implementation("io.github.aalsanie:codes:0.3.1") + implementation("io.github.aalsanie:codes:0.4.0-RC1") } ``` -Maven: +Spring: -```xml - - io.github.aalsanie - codes - 0.3.1 - +```kotlin +dependencies { + implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") +} +``` + +gRPC Java: + +```kotlin +dependencies { + implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") +} ``` -Java 17+. Zero runtime dependencies. Kotlin applications consume the same Java API with JSpecify nullability metadata. +All artifacts require Java 17+. The core artifact has zero runtime dependencies. The Spring and gRPC artifacts depend only on the boundary libraries they adapt. + +For a boundary-first walkthrough: + +* [Spring in ten minutes](docs/ten-minute-spring.md) +* [gRPC Java in ten minutes](docs/ten-minute-grpc.md) ## Custom outcomes @@ -147,11 +160,29 @@ check(outcome.code == StandardOutcomes.NOT_FOUND.code) check(status?.value == 404) ``` +## When not to use Codes + +Do not add Codes only to standardize a single controller's error body. Framework-native errors are usually enough for a small application with one boundary. + +Codes is also the wrong tool when: + +* the application does not need a stable outcome identity outside one protocol boundary; +* you want a `Result`, `Either`, validation framework, exception hierarchy, or business workflow engine; +* you want Spring Boot auto-configuration, exception scanning, annotations, or hidden mapping conventions; +* an existing public error schema is fixed and migration cost is larger than the value of cross-boundary identity; +* you need protocol adapters beyond the ones Codes actually provides and do not want to own that adapter; +* you need a central outcome registry, governance service, code generator, or schema distribution system; +* the application has not yet decided which domain outcomes are stable enough to become machine identities. + +Codes is useful when the identity itself matters independently of HTTP or gRPC. If that is not true, another abstraction is probably unnecessary. + ## Reference + * [Semantic contract](docs/semantic-contract.md) * [HTTP and gRPC mappings](docs/protocol-mappings.md) * [Compatibility policy](docs/compatibility-policy.md) * [Artifact contracts](docs/artifact-contracts.md) +* [RC1 real-adopter gate](docs/rc1-adopter-gate.md) ## License diff --git a/SECURITY.md b/SECURITY.md index aac6568..38b9d92 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,47 @@ # Security -## Reporting +## Reporting a vulnerability -Do not open a public issue for a vulnerability that could put users at risk before a fix is available. +Do **not** disclose a suspected vulnerability in a public GitHub issue, pull request, discussion, or comment. -Use GitHub's private vulnerability reporting for this repository when available. If private reporting is unavailable, contact the maintainer through the contact information on the GitHub profile and provide the affected version, impact, reproduction steps, and any proposed mitigation. +Use GitHub's private vulnerability reporting / Security Advisories for this repository. + +If private reporting is temporarily unavailable, contact the maintainer using the contact information published on the maintainer's GitHub profile and clearly mark the message as a security report. + +Do not use a public issue as a fallback. + +Please include, when applicable: + +- affected Codes version +- affected artifact: `codes`, `codes-spring`, or `codes-grpc-java` +- affected surface: core API, Spring boundary, gRPC boundary, publication, or build tooling +- Java and Kotlin versions +- Spring or gRPC version +- Gradle or Maven version +- operating system +- minimal reproduction steps +- impact and required preconditions +- any proof-of-concept material needed to reproduce safely +- any known mitigation + +The maintainer will review valid private reports, coordinate remediation, and publish a security advisory when disclosure is appropriate. + +Please avoid public disclosure until a fix or coordinated disclosure date is available. ## Supported versions -Security fixes are applied to the latest released minor line. Pre-1.0 versions may require upgrading to receive a fix. +Security fixes target the latest stable release line. + +Pre-release versions may receive a fix through a newer release candidate or the final release rather than a patch to the affected pre-release. + +Older release lines are not guaranteed to receive security fixes. ## Boundary data -Codes treats occurrence `detail` and `Issue` content as application-controlled data. Framework adapters do not expose those fields by default. See `docs/boundary-exposure.md`. +Codes can carry application-controlled occurrence `detail` and structured `Issue` content. + +The Spring and gRPC adapters use conservative defaults and do not expose protected occurrence detail unless the application explicitly opts in. + +A case where safe defaults expose protected data is security-relevant and should be reported privately. + +Suspected compromise of a published artifact, signature, or release process should also be reported privately. diff --git a/adoption/rc1/integration-reliability-platform.patch b/adoption/rc1/integration-reliability-platform.patch new file mode 100644 index 0000000..e979e18 --- /dev/null +++ b/adoption/rc1/integration-reliability-platform.patch @@ -0,0 +1,159 @@ +--- a/build.gradle.kts ++++ b/build.gradle.kts +@@ -6,6 +6,8 @@ + + group = "io.github.aalsanie" + version = "0.0.1-SNAPSHOT" ++ ++val codesVersion = providers.gradleProperty("codesVersion").orElse("0.4.0-RC1") + + java { + toolchain { +@@ -18,6 +20,7 @@ + } + + dependencies { ++ implementation("io.github.aalsanie:codes-spring:${codesVersion.get()}") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.springframework.boot:spring-boot-starter-validation") +--- a/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java ++++ b/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java +@@ -1,5 +1,8 @@ + package io.github.aalsanie.irp.common.api; + ++import io.github.aalsanie.codes.Outcome; ++import io.github.aalsanie.codes.StandardOutcomes; ++import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; + import io.github.aalsanie.irp.connections.DuplicateConnectionException; + import io.github.aalsanie.irp.events.DuplicateInboundEventException; + import io.github.aalsanie.irp.events.EventNotFoundException; +@@ -7,6 +10,7 @@ + import io.github.aalsanie.irp.events.InvalidEventProcessingStatus; + import jakarta.servlet.http.HttpServletRequest; + import org.springframework.http.HttpStatus; ++import org.springframework.http.ProblemDetail; + import org.springframework.http.ResponseEntity; + import org.springframework.web.bind.annotation.ExceptionHandler; + import org.springframework.web.bind.annotation.RestControllerAdvice; +@@ -15,6 +19,8 @@ + + @RestControllerAdvice + public class GlobalExceptionHandler { ++ private static final OutcomeProblemDetailMapper CODES_PROBLEMS = ++ OutcomeProblemDetailMapper.safeDefaults(); + + @ExceptionHandler(value = {DuplicateConnectionException.class}) + public ResponseEntity handleException(DuplicateConnectionException exception, +@@ -53,14 +59,16 @@ + } + + @ExceptionHandler(value = {DuplicateInboundEventException.class}) +- public ResponseEntity handleException(DuplicateInboundEventException exception, HttpServletRequest request) { +- HttpStatus status = HttpStatus.CONFLICT; +- ApiErrorResponse response = new ApiErrorResponse(Instant.now(), +- status.value(), +- exception.getMessage(), +- status.getReasonPhrase(), +- request.getRequestURI()); +- return ResponseEntity.status(HttpStatus.CONFLICT).body(response); ++ public ResponseEntity handleException(DuplicateInboundEventException exception) { ++ ProblemDetail problem = CODES_PROBLEMS ++ .map(Outcome.of(StandardOutcomes.ALREADY_EXISTS)) ++ .orNull(); ++ ++ if (problem == null) { ++ throw new IllegalStateException("ALREADY_EXISTS has no HTTP mapping", exception); ++ } ++ ++ return ResponseEntity.status(problem.getStatus()).body(problem); + } + + @ExceptionHandler(value = {InvalidEventProcessingStatus.class}) +--- /dev/null ++++ b/src/test/java/io/github/aalsanie/irp/CodesSpringAdoptionTest.java +@@ -0,0 +1,84 @@ ++package io.github.aalsanie.irp; ++ ++import io.github.aalsanie.irp.connections.ConnectionStatus; ++import io.github.aalsanie.irp.connections.IntegrationConnection; ++import io.github.aalsanie.irp.connections.IntegrationConnectionRepository; ++import io.github.aalsanie.irp.events.EventRepository; ++import org.junit.jupiter.api.BeforeEach; ++import org.junit.jupiter.api.Test; ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.boot.test.context.SpringBootTest; ++import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; ++import org.springframework.context.annotation.Import; ++import org.springframework.http.MediaType; ++import org.springframework.test.web.servlet.MockMvc; ++ ++import java.time.Instant; ++import java.util.UUID; ++ ++import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; ++ ++@SpringBootTest ++@AutoConfigureMockMvc ++@Import(TestcontainersConfiguration.class) ++class CodesSpringAdoptionTest { ++ ++ @Autowired ++ private MockMvc mockMvc; ++ ++ @Autowired ++ private EventRepository eventRepository; ++ ++ @Autowired ++ private IntegrationConnectionRepository connectionRepository; ++ ++ @BeforeEach ++ void setUp() { ++ eventRepository.deleteAll(); ++ connectionRepository.deleteAll(); ++ } ++ ++ @Test ++ void duplicateInboundEventKeepsStableCodesIdentityWithoutOccurrenceDetail() ++ throws Exception { ++ IntegrationConnection connection = connectionRepository.saveAndFlush( ++ new IntegrationConnection( ++ UUID.randomUUID(), ++ "pilot", ++ "stripe", ++ ConnectionStatus.ACTIVE, ++ Instant.now() ++ ) ++ ); ++ ++ String request = """ ++ { ++ "externalEventId": "evt_rc1", ++ "eventType": "payment.succeeded", ++ "payload": { ++ "paymentId": "pay_rc1" ++ } ++ } ++ """; ++ ++ String endpoint = "/api/v1/connections/" + connection.getId() + "/events"; ++ ++ mockMvc.perform(post(endpoint) ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(request)) ++ .andExpect(status().isCreated()); ++ ++ mockMvc.perform(post(endpoint) ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(request)) ++ .andExpect(status().isConflict()) ++ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) ++ .andExpect(jsonPath("$.status").value(409)) ++ .andExpect(jsonPath("$.code") ++ .value("io.github.aalsanie.codes.standard:ALREADY_EXISTS")) ++ .andExpect(jsonPath("$.detail").doesNotExist()); ++ } ++} diff --git a/adoption/rc1/manifest.json b/adoption/rc1/manifest.json new file mode 100644 index 0000000..12a6dbf --- /dev/null +++ b/adoption/rc1/manifest.json @@ -0,0 +1,29 @@ +{ + "version": "0.4.0-RC1", + "pilots": [ + { + "id": "integration-reliability-platform", + "repository": "https://github.com/aalsanie/integration-reliability-platform.git", + "commit": "1f99ad8ec4b0b7da2a20ab20101a6adb677cb735", + "patch": "adoption/rc1/integration-reliability-platform.patch", + "boundary": "Spring MVC", + "differentiator": "incremental replacement of a handwritten HTTP error mapping", + "manual_mapping_removed": "1 handwritten duplicate-event HTTP status/payload mapping", + "dependency_conflicts": "None expected; the application already uses Spring Boot 4.1.0 and owns Spring dependency management.", + "missing_api": "None identified before execution.", + "expected_identity": "io.github.aalsanie.codes.standard:ALREADY_EXISTS" + }, + { + "id": "patient-mgmt-microservices", + "repository": "https://github.com/pratham2402/patient-mgmt-microservices.git", + "commit": "26645990986a4f17b755ec17ed390c9e90112d36", + "patch": "adoption/rc1/patient-mgmt-microservices.patch", + "boundary": "Spring MVC + gRPC Java", + "differentiator": "the same INVALID_ARGUMENT identity is exercised through two real protocol boundaries", + "manual_mapping_removed": "1 handwritten Spring validation Map response; the gRPC validation path did not previously exist", + "dependency_conflicts": "The application pins gRPC 1.69.0, below the Codes 1.75.0 floor. The pilot upgrades the application gRPC line and protoc gRPC plugin to 1.75.0 and imports the 1.75.0 gRPC BOM.", + "missing_api": "None identified before execution.", + "expected_identity": "io.github.aalsanie.codes.standard:INVALID_ARGUMENT" + } + ] +} diff --git a/adoption/rc1/patient-mgmt-microservices.patch b/adoption/rc1/patient-mgmt-microservices.patch new file mode 100644 index 0000000..d292568 --- /dev/null +++ b/adoption/rc1/patient-mgmt-microservices.patch @@ -0,0 +1,365 @@ +--- a/patient-service/pom.xml ++++ b/patient-service/pom.xml +@@ -28,8 +28,14 @@ + + + 21 ++ 0.4.0-RC1 + + ++ ++ io.github.aalsanie ++ codes-spring ++ ${codes.version} ++ + + org.springframework.boot + spring-boot-starter-data-jpa +--- a/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java ++++ b/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java +@@ -1,31 +1,57 @@ + package com.pm.patientservice.exception; + +-import jakarta.validation.constraints.Email; ++import io.github.aalsanie.codes.Issue; ++import io.github.aalsanie.codes.Outcome; ++import io.github.aalsanie.codes.StandardOutcomes; ++import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; ++import io.github.aalsanie.codes.spring.SpringHttpStatusMapper; ++import io.github.aalsanie.codes.spring.SpringOutcomeExposure; ++import io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper; + import org.slf4j.Logger; + import org.slf4j.LoggerFactory; ++import org.springframework.http.ProblemDetail; + import org.springframework.http.ResponseEntity; + import org.springframework.web.bind.MethodArgumentNotValidException; + import org.springframework.web.bind.annotation.ControllerAdvice; + import org.springframework.web.bind.annotation.ExceptionHandler; + ++import java.util.ArrayList; + import java.util.HashMap; ++import java.util.List; + import java.util.Map; + + @ControllerAdvice + public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); ++ private static final OutcomeProblemDetailMapper VALIDATION_PROBLEMS = ++ new OutcomeProblemDetailMapper( ++ SpringHttpStatusMapper.standard(), ++ SpringOutcomeExposure.publicErrors(), ++ SpringProblemTypeUriMapper.empty() ++ ); + + @ExceptionHandler(MethodArgumentNotValidException.class) +- public ResponseEntity> handleValidationException( ++ public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex) { + +- Map errors = new HashMap<>(); ++ List issues = new ArrayList<>(); ++ ex.getBindingResult().getFieldErrors().forEach(error -> { ++ String message = error.getDefaultMessage() == null ++ ? "Invalid value." ++ : error.getDefaultMessage(); ++ issues.add(Issue.at(error.getField(), message)); ++ }); + +- ex.getBindingResult().getFieldErrors().forEach( +- error -> errors.put(error.getField(), error.getDefaultMessage())); ++ ProblemDetail problem = VALIDATION_PROBLEMS ++ .map(Outcome.of(StandardOutcomes.INVALID_ARGUMENT, null, issues)) ++ .orNull(); + +- return ResponseEntity.badRequest().body(errors); ++ if (problem == null) { ++ throw new IllegalStateException("INVALID_ARGUMENT has no HTTP mapping"); ++ } ++ ++ return ResponseEntity.status(problem.getStatus()).body(problem); + } + + @ExceptionHandler(EmailAlreadyExistsException.class) +--- /dev/null ++++ b/patient-service/src/test/java/com/pm/patientservice/exception/CodesValidationBoundaryTest.java +@@ -0,0 +1,55 @@ ++package com.pm.patientservice.exception; ++ ++import jakarta.validation.Valid; ++import jakarta.validation.constraints.NotBlank; ++import org.junit.jupiter.api.Test; ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; ++import org.springframework.context.annotation.Import; ++import org.springframework.http.MediaType; ++import org.springframework.web.bind.annotation.PostMapping; ++import org.springframework.web.bind.annotation.RequestBody; ++import org.springframework.web.bind.annotation.RestController; ++import org.springframework.test.web.servlet.MockMvc; ++ ++import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; ++import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; ++ ++@WebMvcTest(controllers = CodesValidationBoundaryTest.PilotController.class) ++@Import(GlobalExceptionHandler.class) ++class CodesValidationBoundaryTest { ++ ++ @Autowired ++ private MockMvc mockMvc; ++ ++ @Test ++ void validationKeepsCodesIdentityAndStructuredIssuesAtHttpBoundary() ++ throws Exception { ++ mockMvc.perform(post("/codes-pilot/validate") ++ .contentType(MediaType.APPLICATION_JSON) ++ .content(""" ++ { ++ "email": "" ++ } ++ """)) ++ .andExpect(status().isBadRequest()) ++ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) ++ .andExpect(jsonPath("$.status").value(400)) ++ .andExpect(jsonPath("$.code") ++ .value("io.github.aalsanie.codes.standard:INVALID_ARGUMENT")) ++ .andExpect(jsonPath("$.issues[0].path").value("email")) ++ .andExpect(jsonPath("$.detail").doesNotExist()); ++ } ++ ++ @RestController ++ static class PilotController { ++ @PostMapping("/codes-pilot/validate") ++ void validate(@Valid @RequestBody PilotRequest request) { ++ } ++ } ++ ++ record PilotRequest(@NotBlank String email) { ++ } ++} +--- a/billing-service/pom.xml ++++ b/billing-service/pom.xml +@@ -28,8 +28,26 @@ + + + 21 ++ 0.4.0-RC1 ++ 1.75.0 + ++ ++ ++ ++ io.grpc ++ grpc-bom ++ ${grpc.version} ++ pom ++ import ++ ++ ++ + ++ ++ io.github.aalsanie ++ codes-grpc-java ++ ${codes.version} ++ + + org.springframework.boot + spring-boot-starter-web +@@ -45,17 +63,17 @@ + + io.grpc + grpc-netty-shaded +- 1.69.0 ++ ${grpc.version} + + + io.grpc + grpc-protobuf +- 1.69.0 ++ ${grpc.version} + + + io.grpc + grpc-stub +- 1.69.0 ++ ${grpc.version} + + + org.apache.tomcat +@@ -103,7 +121,7 @@ + com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier} + grpc-java + +- io.grpc:protoc-gen-grpc-java:1.68.1:exe:${os.detected.classifier} ++ io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + +--- a/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java ++++ b/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java +@@ -1,21 +1,58 @@ + package com.pm.billingservice.grpc; + ++import billing.BillingRequest; + import billing.BillingResponse; + import billing.BillingServiceGrpc.BillingServiceImplBase; ++import io.github.aalsanie.codes.Issue; ++import io.github.aalsanie.codes.Outcome; ++import io.github.aalsanie.codes.StandardOutcomes; ++import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; ++import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; ++import io.github.aalsanie.codes.grpc.GrpcOutcomeExposure; ++import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; ++import io.grpc.StatusRuntimeException; + import io.grpc.stub.StreamObserver; + import net.devh.boot.grpc.server.service.GrpcService; + import org.slf4j.Logger; + import org.slf4j.LoggerFactory; + ++import java.util.ArrayList; ++import java.util.List; ++ + @GrpcService + public class BillingGrpcService extends BillingServiceImplBase { + private static final Logger log = LoggerFactory.getLogger(BillingGrpcService.class); ++ private static final GoogleRpcOutcomeMapper ERRORS = ++ new GoogleRpcOutcomeMapper( ++ GrpcOutcomeMapper.standard(), ++ GrpcOutcomeExposure.publicErrors() ++ ); + + @Override +- public void createBillingAccount(billing.BillingRequest billingRequest, ++ public void createBillingAccount(BillingRequest billingRequest, + StreamObserver responseObserver) { + + log.info("createBillingAccount request received {}", billingRequest.toString()); ++ ++ List issues = validationIssues(billingRequest); ++ if (!issues.isEmpty()) { ++ Outcome outcome = Outcome.of( ++ StandardOutcomes.INVALID_ARGUMENT, ++ null, ++ issues ++ ); ++ ++ StatusRuntimeException error = GrpcOutcomeExceptions ++ .toStatusRuntimeException(outcome, ERRORS) ++ .orNull(); ++ ++ if (error == null) { ++ throw new IllegalStateException("INVALID_ARGUMENT has no gRPC mapping"); ++ } ++ ++ responseObserver.onError(error); ++ return; ++ } + + // Business logic - e.g. save to database, perform calculations, etc + +@@ -27,4 +64,20 @@ + responseObserver.onNext(response); + responseObserver.onCompleted(); + } ++ ++ private static List validationIssues(BillingRequest request) { ++ List issues = new ArrayList<>(); ++ ++ if (request.getPatientId().isBlank()) { ++ issues.add(Issue.at("patientId", "Patient id is required.")); ++ } ++ if (request.getName().isBlank()) { ++ issues.add(Issue.at("name", "Name is required.")); ++ } ++ if (request.getEmail().isBlank()) { ++ issues.add(Issue.at("email", "Email is required.")); ++ } ++ ++ return issues; ++ } + } +--- /dev/null ++++ b/billing-service/src/test/java/com/pm/billingservice/grpc/BillingGrpcServiceCodesTest.java +@@ -0,0 +1,79 @@ ++package com.pm.billingservice.grpc; ++ ++import billing.BillingRequest; ++import billing.BillingResponse; ++import com.google.protobuf.Any; ++import com.google.rpc.BadRequest; ++import com.google.rpc.ErrorInfo; ++import io.grpc.StatusRuntimeException; ++import io.grpc.protobuf.StatusProto; ++import io.grpc.stub.StreamObserver; ++import org.junit.jupiter.api.Test; ++ ++import java.util.concurrent.atomic.AtomicReference; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertInstanceOf; ++import static org.junit.jupiter.api.Assertions.assertNotNull; ++import static org.junit.jupiter.api.Assertions.assertNull; ++ ++class BillingGrpcServiceCodesTest { ++ ++ @Test ++ void invalidRequestKeepsCodesIdentityAndStructuredIssuesInTrailers() ++ throws Exception { ++ AtomicReference failure = new AtomicReference<>(); ++ AtomicReference response = new AtomicReference<>(); ++ ++ StreamObserver observer = new StreamObserver<>() { ++ @Override ++ public void onNext(BillingResponse value) { ++ response.set(value); ++ } ++ ++ @Override ++ public void onError(Throwable throwable) { ++ failure.set(throwable); ++ } ++ ++ @Override ++ public void onCompleted() { ++ } ++ }; ++ ++ new BillingGrpcService().createBillingAccount( ++ BillingRequest.getDefaultInstance(), ++ observer ++ ); ++ ++ assertNull(response.get()); ++ ++ StatusRuntimeException exception = ++ assertInstanceOf(StatusRuntimeException.class, failure.get()); ++ ++ com.google.rpc.Status status = StatusProto.fromThrowable(exception); ++ assertNotNull(status); ++ assertEquals(3, status.getCode()); ++ ++ ErrorInfo identity = null; ++ BadRequest badRequest = null; ++ ++ for (Any detail : status.getDetailsList()) { ++ if (detail.is(ErrorInfo.class)) { ++ identity = detail.unpack(ErrorInfo.class); ++ } else if (detail.is(BadRequest.class)) { ++ badRequest = detail.unpack(BadRequest.class); ++ } ++ } ++ ++ assertNotNull(identity); ++ assertEquals("io.github.aalsanie.codes.standard", identity.getDomain()); ++ assertEquals("INVALID_ARGUMENT", identity.getReason()); ++ ++ assertNotNull(badRequest); ++ assertEquals(3, badRequest.getFieldViolationsCount()); ++ assertEquals("patientId", badRequest.getFieldViolations(0).getField()); ++ assertEquals("name", badRequest.getFieldViolations(1).getField()); ++ assertEquals("email", badRequest.getFieldViolations(2).getField()); ++ } ++} diff --git a/docs/rc1-adopter-gate.md b/docs/rc1-adopter-gate.md new file mode 100644 index 0000000..36a2bb6 --- /dev/null +++ b/docs/rc1-adopter-gate.md @@ -0,0 +1,89 @@ +# 0.4.0-RC1 real-adopter gate + +The RC is not accepted because it has downloads, stars, or because the Codes repository's own reference applications compile. + +The gate uses two independent codebases outside this repository at pinned commits and applies small application patches that consume the published Maven Central artifacts. + +## Pilot 1 — Integration Reliability Platform + +Repository: `aalsanie/integration-reliability-platform` + +Pinned commit: `1f99ad8ec4b0b7da2a20ab20101a6adb677cb735` + +Environment: + +* Java 21 +* Spring Boot 4.1.0 +* Gradle +* existing Spring MVC error boundary +* existing Testcontainers integration suite + +The pilot replaces one existing handwritten duplicate-event HTTP mapping with `codes-spring`. The existing application remains responsible for deciding that `DuplicateInboundEventException` means `ALREADY_EXISTS`. + +Expected identity: + +```text +io.github.aalsanie.codes.standard:ALREADY_EXISTS +``` + +The pilot includes a real MockMvc integration test against the application boundary. + +## Pilot 2 — Patient Management Microservices + +Repository: `pratham2402/patient-mgmt-microservices` + +Pinned commit: `26645990986a4f17b755ec17ed390c9e90112d36` + +Environment: + +* Java 21 +* Spring Boot 3.5.5 +* Maven +* REST patient service +* gRPC billing service + +This pilot exercises the actual differentiator. + +The patient service replaces its handwritten validation `Map` response with `codes-spring`. The billing service adds validation through `codes-grpc-java`. + +Both boundaries use: + +```text +io.github.aalsanie.codes.standard:INVALID_ARGUMENT +``` + +The HTTP side verifies the RFC 9457 `code` property and structured issues. The gRPC side decodes a real `StatusRuntimeException` and verifies `ErrorInfo` plus `BadRequest`. + +The repository currently pins gRPC 1.69.0. Codes declares 1.75.0 as its `0.4.x` compatibility floor, so the pilot upgrades the application's gRPC dependency line and protoc gRPC plugin to 1.75.0. That is recorded as an adopter dependency conflict; it is not hidden with a Codes patch. + +## What the automated report records + +For each pilot the gate records: + +* repository and pinned commit; +* exact patch; +* pass/fail result; +* automated clone + patch + clean build/test elapsed time; +* changed-line statistics; +* manual boundary mapping removed; +* dependency conflicts; +* missing Codes API observed by the successful integration; +* whether any custom Codes library patch was used. + +The elapsed time is machine integration time from a clean CI workspace. It is not presented as human coding time. + +A successful report may state `Missing API: None observed`. A failed build is not converted into that claim; it fails the gate for investigation. + +## What counts as success + +Both external repositories must: + +1. clone at the pinned commit; +2. accept the stored application patch with `git apply --check`; +3. resolve `0.4.0-RC1` from Maven Central; +4. build and test without a source or binary patch to Codes; +5. verify the intended boundary identity. + +Only then is the Step 5 exit gate green. + +The patches are reproducible adopter experiments. They are not claims that the upstream maintainers have merged Codes. diff --git a/docs/ten-minute-grpc.md b/docs/ten-minute-grpc.md new file mode 100644 index 0000000..f3c53a4 --- /dev/null +++ b/docs/ten-minute-grpc.md @@ -0,0 +1,96 @@ +# gRPC Java in ten minutes + +This example adds Codes at a gRPC server boundary. It does not replace the service's domain model. + +## 1. Add the adapter + +```kotlin +dependencies { + implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") +} +``` + +Codes `0.4.x` declares gRPC Java 1.75.0 as its compatibility floor. + +## 2. Define the stable application outcome + +```java +final class PaymentOutcomes { + static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( + "com.example.payments", + "PAYMENT_DECLINED", + OutcomeState.FAILED, + "The payment was declined." + ); + + private PaymentOutcomes() { + } +} +``` + +For `ErrorInfo.reason`, the outcome name must satisfy the Google RPC reason contract: at most 63 characters and UPPER_SNAKE_CASE without a trailing underscore. Codes rejects a lossy mapping rather than changing the identity. + +## 3. Configure the gRPC mapping + +```java +GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() + .withMapping( + PaymentOutcomes.PAYMENT_DECLINED, + GrpcStatusCode.FAILED_PRECONDITION + ); + +GoogleRpcOutcomeMapper errors = new GoogleRpcOutcomeMapper( + grpc, + GrpcOutcomeExposure.publicErrors() +); +``` + +`publicErrors()` exposes the reusable message and structured issues. It does not expose occurrence `detail`. + +## 4. Send the error through the existing service + +```java +Outcome outcome = Outcome.of( + PaymentOutcomes.PAYMENT_DECLINED, + null, + List.of(Issue.at("paymentMethod", "Payment method is unavailable.")) +); + +StatusRuntimeException error = GrpcOutcomeExceptions + .toStatusRuntimeException(outcome, errors) + .orNull(); + +if (error == null) { + throw new IllegalStateException("PAYMENT_DECLINED has no gRPC mapping"); +} + +responseObserver.onError(error); +``` + +The client receives a real `google.rpc.Status` in the gRPC trailers. + +## Decode it + +```java +com.google.rpc.Status status = StatusProto.fromThrowable(error); + +ErrorInfo identity = status.getDetailsList().stream() + .filter(any -> any.is(ErrorInfo.class)) + .findFirst() + .orElseThrow() + .unpack(ErrorInfo.class); + +assert identity.getDomain().equals("com.example.payments"); +assert identity.getReason().equals("PAYMENT_DECLINED"); +``` + +Structured issues are carried in `google.rpc.BadRequest`. Occurrence detail is carried only when `exposeDetail` is explicitly enabled. + +The application identity therefore survives: + +```text +com.example.payments:PAYMENT_DECLINED + -> gRPC FAILED_PRECONDITION + -> ErrorInfo.domain = com.example.payments + -> ErrorInfo.reason = PAYMENT_DECLINED +``` diff --git a/docs/ten-minute-spring.md b/docs/ten-minute-spring.md new file mode 100644 index 0000000..679e781 --- /dev/null +++ b/docs/ten-minute-spring.md @@ -0,0 +1,110 @@ +# Spring in ten minutes + +This example adds Codes at the HTTP boundary only. It does not replace the application's exception or domain model. + +## 1. Add the adapter + +```kotlin +dependencies { + implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") +} +``` + +The adapter supports the declared Spring 6 and Spring 7 compatibility lines. + +## 2. Define the stable application outcome + +```java +final class PaymentOutcomes { + static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( + "com.example.payments", + "PAYMENT_DECLINED", + OutcomeState.FAILED, + "The payment was declined." + ); + + private PaymentOutcomes() { + } +} +``` + +`com.example.payments:PAYMENT_DECLINED` is the identity. HTTP 422 is a boundary decision. + +## 3. Configure the HTTP boundary explicitly + +```java +HttpOutcomeMapper http = HttpOutcomeMapper.standard() + .withMapping(PaymentOutcomes.PAYMENT_DECLINED, HttpStatusCode.of(422)); + +OutcomeProblemDetailMapper problems = new OutcomeProblemDetailMapper( + new SpringHttpStatusMapper(http), + SpringOutcomeExposure.publicErrors(), + SpringProblemTypeUriMapper.empty().withMapping( + PaymentOutcomes.PAYMENT_DECLINED, + URI.create("https://api.example.com/problems/payment-declined") + ) +); +``` + +The URI above belongs to the example application. Codes does not invent or own problem-type URIs. + +`publicErrors()` exposes the reusable outcome message and structured issues but not occurrence `detail`. Use `safeDefaults()` when even those fields should remain hidden. + +## 4. Use it from the application's existing exception boundary + +```java +@RestControllerAdvice +final class PaymentExceptionHandler { + private final OutcomeProblemDetailMapper problems; + + PaymentExceptionHandler(OutcomeProblemDetailMapper problems) { + this.problems = problems; + } + + @ExceptionHandler(PaymentDeclinedException.class) + ResponseEntity paymentDeclined() { + Outcome outcome = Outcome.of(PaymentOutcomes.PAYMENT_DECLINED); + ProblemDetail problem = problems.map(outcome).orNull(); + + if (problem == null) { + throw new IllegalStateException("PAYMENT_DECLINED has no HTTP mapping"); + } + + return ResponseEntity.status(problem.getStatus()).body(problem); + } +} +``` + +Codes is not discovering exceptions. The application still decides which exception means which outcome. + +## Result + +A rendered response is an RFC 9457 problem document with the stable Codes identity: + +```json +{ + "type": "https://api.example.com/problems/payment-declined", + "title": "The payment was declined.", + "status": 422, + "code": "com.example.payments:PAYMENT_DECLINED" +} +``` + +Occurrence detail is absent unless the application explicitly opts into it. + +## Validation issues + +```java +Outcome invalid = Outcome.of( + StandardOutcomes.INVALID_ARGUMENT, + null, + List.of( + Issue.at("email", "Invalid email address."), + Issue.at("quantity", "Must be greater than zero.") + ) +); +``` + +With `SpringOutcomeExposure.publicErrors()`, those issues are emitted as structured `issues` while `code` remains `io.github.aalsanie.codes.standard:INVALID_ARGUMENT`. + +That is the complete integration: stable outcome definition, explicit protocol mapping, explicit exposure policy, existing application boundary. diff --git a/gradle.properties b/gradle.properties index a99b383..4fe73f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ GROUP=io.github.aalsanie POM_ARTIFACT_ID=codes -VERSION_NAME=0.4.0-SNAPSHOT +VERSION_NAME=0.4.0-RC1 POM_NAME=Codes POM_DESCRIPTION=Stable application outcomes, structured issues, and explicit protocol mappings for Java and Kotlin. POM_INCEPTION_YEAR=2026 diff --git a/scripts/verify-rc-adopters.sh b/scripts/verify-rc-adopters.sh new file mode 100644 index 0000000..7deb9ff --- /dev/null +++ b/scripts/verify-rc-adopters.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +manifest="$root_dir/adoption/rc1/manifest.json" +version="${1:-0.4.0-RC1}" +report="${2:-$root_dir/build/reports/rc1-adopters.md}" +work_dir="${RC_ADOPTER_WORK_DIR:-$root_dir/build/rc-adopters}" + +manifest_version="$(python - "$manifest" <<'PY' +import json +import sys +from pathlib import Path +print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["version"]) +PY +)" + +if [[ "$version" != "$manifest_version" ]]; then + echo "RC adopter manifest is for $manifest_version, not $version." >&2 + exit 1 +fi + +central_base="https://repo.maven.apache.org/maven2/io/github/aalsanie" +for artifact in codes codes-spring codes-grpc-java; do + url="$central_base/$artifact/$version/$artifact-$version.pom" + found=false + + for attempt in $(seq 1 40); do + if curl --silent --show-error --fail --head "$url" >/dev/null 2>&1; then + found=true + break + fi + sleep 15 + done + + if [[ "$found" != true ]]; then + echo "Published artifact did not become visible on Maven Central: $url" >&2 + exit 1 + fi +done + +rm -rf "$work_dir" +mkdir -p "$work_dir" "$(dirname "$report")" + +{ + echo "# Codes $version real-adopter gate" + echo + echo "Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo + echo "Codes library patches used by pilots: **0**" + echo + echo "Both pilots resolve Codes from Maven Central. No composite build, Maven Local repository, or source substitution is used." + echo "Integration time below is automated clean clone + patch + build/test time, not human coding time." + echo +} > "$report" + +overall=0 + +pilot_value() { + local pilot_id="$1" + local field="$2" + python - "$manifest" "$pilot_id" "$field" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +pilot = next(p for p in manifest["pilots"] if p["id"] == sys.argv[2]) +print(pilot[sys.argv[3]]) +PY +} + +run_irp() { + local id="integration-reliability-platform" + local repo commit patch_dir patch_file start end elapsed status log + + repo="$(pilot_value "$id" repository)" + commit="$(pilot_value "$id" commit)" + patch_file="$root_dir/$(pilot_value "$id" patch)" + patch_dir="$work_dir/$id" + log="$work_dir/$id.log" + + git clone --quiet --filter=blob:none --no-checkout "$repo" "$patch_dir" + git -C "$patch_dir" checkout --quiet --detach "$commit" + git -C "$patch_dir" apply --check "$patch_file" + git -C "$patch_dir" apply "$patch_file" + + start="$(date +%s)" + status="PASS" + + if ! ( + cd "$patch_dir" + bash ./gradlew \ + "-PcodesVersion=$version" \ + clean test \ + --no-build-cache \ + --stacktrace + + bash ./gradlew \ + "-PcodesVersion=$version" \ + dependencyInsight \ + --dependency codes-spring \ + --configuration runtimeClasspath \ + --no-build-cache + ) >"$log" 2>&1; then + status="FAIL" + overall=1 + fi + + end="$(date +%s)" + elapsed="$((end - start))" + + { + echo "## $id" + echo + echo "- Repository: \`$repo\`" + echo "- Commit: \`$commit\`" + echo "- Boundary: $(pilot_value "$id" boundary)" + echo "- Differentiator: $(pilot_value "$id" differentiator)" + echo "- Result: **$status**" + echo "- Automated clean integration/build time: ${elapsed}s" + echo "- Manual mapping removed: $(pilot_value "$id" manual_mapping_removed)" + echo "- Dependency conflicts: $(pilot_value "$id" dependency_conflicts)" + if [[ "$status" == "PASS" ]]; then + echo "- Missing Codes API: None observed." + else + echo "- Missing Codes API: unresolved; inspect the failed build before classifying." + fi + echo "- Expected identity: \`$(pilot_value "$id" expected_identity)\`" + echo "- Custom Codes library patches: 0" + echo + echo "Patch statistics:" + echo + echo '```text' + git -C "$patch_dir" apply --stat "$patch_file" + echo '```' + echo + echo "Build log: \`$(basename "$log")\`" + echo + } >> "$report" +} + +run_patient() { + local id="patient-mgmt-microservices" + local repo commit patch_dir patch_file start end elapsed status log + + repo="$(pilot_value "$id" repository)" + commit="$(pilot_value "$id" commit)" + patch_file="$root_dir/$(pilot_value "$id" patch)" + patch_dir="$work_dir/$id" + log="$work_dir/$id.log" + + git clone --quiet --filter=blob:none --no-checkout "$repo" "$patch_dir" + git -C "$patch_dir" checkout --quiet --detach "$commit" + git -C "$patch_dir" apply --check "$patch_file" + git -C "$patch_dir" apply "$patch_file" + + start="$(date +%s)" + status="PASS" + + if ! ( + cd "$patch_dir/patient-service" + bash ./mvnw \ + --batch-mode \ + --no-transfer-progress \ + "-Dcodes.version=$version" \ + clean test + + bash ./mvnw \ + --batch-mode \ + --no-transfer-progress \ + "-Dcodes.version=$version" \ + dependency:tree \ + "-Dincludes=io.github.aalsanie:*" + + cd "$patch_dir/billing-service" + bash ./mvnw \ + --batch-mode \ + --no-transfer-progress \ + "-Dcodes.version=$version" \ + clean test + + bash ./mvnw \ + --batch-mode \ + --no-transfer-progress \ + "-Dcodes.version=$version" \ + dependency:tree \ + "-Dincludes=io.github.aalsanie:*,io.grpc:*" + ) >"$log" 2>&1; then + status="FAIL" + overall=1 + fi + + end="$(date +%s)" + elapsed="$((end - start))" + + { + echo "## $id" + echo + echo "- Repository: \`$repo\`" + echo "- Commit: \`$commit\`" + echo "- Boundary: $(pilot_value "$id" boundary)" + echo "- Differentiator: $(pilot_value "$id" differentiator)" + echo "- Result: **$status**" + echo "- Automated clean integration/build time: ${elapsed}s" + echo "- Manual mapping removed: $(pilot_value "$id" manual_mapping_removed)" + echo "- Dependency conflicts: $(pilot_value "$id" dependency_conflicts)" + if [[ "$status" == "PASS" ]]; then + echo "- Missing Codes API: None observed." + else + echo "- Missing Codes API: unresolved; inspect the failed build before classifying." + fi + echo "- Expected identity: \`$(pilot_value "$id" expected_identity)\`" + echo "- Custom Codes library patches: 0" + echo + echo "Patch statistics:" + echo + echo '```text' + git -C "$patch_dir" apply --stat "$patch_file" + echo '```' + echo + echo "Build log: \`$(basename "$log")\`" + echo + } >> "$report" +} + +run_irp +run_patient + +{ + echo "## Gate" + echo + if [[ "$overall" -eq 0 ]]; then + echo "**PASS** — both independent external integrations succeeded from Maven Central without custom Codes library patches." + else + echo "**FAIL** — at least one external integration failed." + fi +} >> "$report" + +cat "$report" +exit "$overall"