From 1f396cf5ba2ec2993297821cfe585496d2b2d983 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:07:28 +0000 Subject: [PATCH 01/22] feat(api): list startable published instruments over HTTP Expose GET /v1/instruments and GET /v1/instruments/{instrument_ref} so a purchaser can copy a published release_ref and locale into POST /v1/sessions. Hide draft, suspended, and retired rows. Keep the OpenAPI 3.2 contract limited to those two operations. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + README.md | 10 + docs/DOCUMENTATION_ASSESSMENT.md | 2 +- docs/TRACEABILITY.md | 6 +- ...4-api-and-event-contract-representation.md | 4 +- docs/doctoring/standards-and-evidence.md | 18 +- openapi/instruments.yaml | 139 ++++++ src/instrument_http.rs | 442 ++++++++++++++++++ src/lib.rs | 1 + tests/documentation_architecture_contract.rs | 31 ++ tests/instrument_http_contract.rs | 334 +++++++++++++ 11 files changed, 981 insertions(+), 7 deletions(-) create mode 100644 openapi/instruments.yaml create mode 100644 src/instrument_http.rs create mode 100644 tests/instrument_http_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..b92e73c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added +- Public in-process instrument catalog HTTP: `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. OpenAPI 3.2 is limited to those two operations. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/README.md b/README.md index 46b10021..bef0de7f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ Measure -> Understand -> Reflect -> Observe Over Time -> Contribute to Science This repository owns the hosted product runtime and integration composition. It consumes reusable measurement contracts and numerical capabilities from `ContextualWisdomLab/fast-mlsirm`; identity and federation from Keyverse; temporal/event analysis from TEPP; and research release/catalog capabilities from `semantic-data-portal`. +## Start with a published instrument + +When the instrument catalog HTTP family is running in this process: + +1. Call `GET /v1/instruments` to list startable published locale-specific releases. +2. Copy one `release_ref` and its exact `locale`. +3. Call `POST /v1/sessions` with those values when the session family is available. + +Draft, suspended, and retired releases are omitted. The as-built contract is [`openapi/instruments.yaml`](openapi/instruments.yaml). + ## Architecture boundary Psychometrics Commons owns product APIs, instrument publication, participant/session lifecycle, response events, consent and data-rights workflows, scoring dispatch, immutable result snapshots, product persistence, resource authorization, reference-client composition, deployment profiles, research-contribution handoff, observability, and service integration. diff --git a/docs/DOCUMENTATION_ASSESSMENT.md b/docs/DOCUMENTATION_ASSESSMENT.md index ccd3c7c1..4b58cf82 100644 --- a/docs/DOCUMENTATION_ASSESSMENT.md +++ b/docs/DOCUMENTATION_ASSESSMENT.md @@ -31,7 +31,7 @@ This reconciliation closes those architecture-definition gaps without promoting | Quality / risk / compliance readiness | **Sufficient as assurance baseline** | Evidence scenarios and risk state are explicit; readiness is not certification. | | Traceability | **Repaired in this reconciliation** | Baseline now names exact protected-main `748876…`, marks newly merged domain modules Implemented/Partial, and isolates PR #24 as Active PR. Must be updated after every material merge. | | Roadmap / agent guidance / changelog | **Sufficient for continued delivery** | Must remain code-current; documentation completion is not a terminal condition for the execution loop. | -| Machine-readable OpenAPI / AsyncAPI | **Not yet applicable as as-built evidence** | Add and validate with the first implemented HTTP/event transport. Do not publish aspirational operations as deployed. | +| Machine-readable OpenAPI / AsyncAPI | **Active PR** instrument catalog HTTP (`openapi/instruments.yaml`) is as-built for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; remaining families and AsyncAPI stay unimplemented | Do not list session, response, consent, or event operations as deployed until those transports exist. | | Physical schema / as-built topology | **Partial / implementation-gated** | Logical ERD is authoritative target semantics. Actual migrations/topology/rollback/restore evidence must be compared to it as those artifacts land. | | Instrument-release evidence bundles | **Target** | Every publishable consumer instrument needs immutable rights, locale/translation, scoring/calibration/norm, DIF/invariance/linking where claimed, scoreability, intended-use and narrative-rule evidence. | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 72bc73c2..7246bc7c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -45,7 +45,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | WCAG 2.2 AA supported reference client | PRD §9.10 | TRD §27; Quality Attributes | ADR-0002, ADR-0013 | Target; no reference client implementation on evaluated main | | EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | External Gyeot/TEPP dependencies + Target Commons enrollment/normalized-ingestion/orchestration adapter | | Measurement Workbench | PRD §6 | C4/component view; UML publication-evidence sequence; Measurement Governance | ADR-0001, ADR-0002, ADR-0004, ADR-0019 | Target; fast-mlsirm/Inkspan/RankWeave are External dependencies | -| Headless replaceable clients | PRD §7 | TRD §1, §18; C4 | ADR-0001, ADR-0002 | Architecture established; public transport is Target | +| Headless replaceable clients | PRD §7 | TRD §1, §18; C4 | ADR-0001, ADR-0002 | Architecture established; Active PR instrument catalog HTTP lists startable published releases; remaining public families are Target | | Community/Hosted/Enterprise profiles | PRD §7, §13 | TRD deployment sections; Deployment/Operations | ADR-0011, ADR-0017 | Target deployment packaging/evidence | ## 3. Technical invariant traceability @@ -128,11 +128,11 @@ migrations/ └── 0012_integration_consumption.sql ``` -Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal normalized ingestion, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. +Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, remaining public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal normalized ingestion, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. ### Active implementation work that is not protected-main truth -**Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. +**Active PR** instrument catalog HTTP is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases from an in-process catalog so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. Session create/reload, persistence, and other public families remain outside this slice. ## 5. ADR traceability by concern diff --git a/docs/adr/0014-api-and-event-contract-representation.md b/docs/adr/0014-api-and-event-contract-representation.md index b8f3bbeb..3e78815d 100644 --- a/docs/adr/0014-api-and-event-contract-representation.md +++ b/docs/adr/0014-api-and-event-contract-representation.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons public/admin HTTP APIs, product-owned durable domain events, errors, schema/version negotiation - Supersedes: none - Superseded by: none -- Current/as-built status: public/admin HTTP transport and durable external event transport are not yet implemented on protected main; current Rust domain contracts are transport-neutral +- Current/as-built status: public/admin HTTP transport and durable external event transport are not yet implemented on protected main; current Rust domain contracts are transport-neutral. Active PR instrument catalog HTTP adds as-built OpenAPI 3.2.0 for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; that slice is not protected-main truth until an unchanged reviewed/check-clean head is integrated. - Target status: every implemented HTTP/event surface has an exact versioned machine-readable as-built contract and deterministic integrity/idempotency semantics - Migration status: no deployed HTTP/event transport requires migration yet; the first implementation must introduce the contract in the same or prerequisite PR @@ -165,7 +165,7 @@ Rollback must restore an application version that still understands any messages - `docs/architecture/UML.md` must not model receipt as equivalent to externally visible side-effect completion. - `docs/architecture/SECURITY_AND_DATA.md` must preserve tenant/purpose boundaries for event payloads and quarantine. - `docs/architecture/DEPLOYMENT_AND_OPERATIONS.md` must include replay/quarantine/recovery evidence when event transport is implemented. -- `docs/TRACEABILITY.md` remains target until as-built OpenAPI/AsyncAPI and transport tests exist. +- `docs/TRACEABILITY.md` remains target for remaining families until as-built OpenAPI/AsyncAPI and transport tests exist. Active PR instrument catalog HTTP names `openapi/instruments.yaml` as the as-built contract for the two GET operations only. ## Validation and release evidence diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index 7879474c..785d6ea4 100644 --- a/docs/doctoring/standards-and-evidence.md +++ b/docs/doctoring/standards-and-evidence.md @@ -1,7 +1,7 @@ # Standards and Evidence Baseline - Status: Living doctoring record -- Last reviewed: 2026-08-11 +- Last reviewed: 2026-08-16 - Scope: Psychometrics Commons product, hosted runtime, reference clients, optional AI, identity integration, and assessment governance This record identifies authoritative standards and primary guidance that materially constrain product design. It is not a certification claim. Each implementation PR that relies on one of these sources must translate the source into a concrete requirement, test, control, or ADR rather than citing it decoratively. @@ -94,6 +94,16 @@ Product consequences: - analysis-set digests bind the exact observations and time semantics consumed by temporal, multilevel, cross-classified, or multiple-membership analysis. +## Public HTTP contracts + +Implemented public HTTP families are described by an exact OpenAPI 3.2.0 as-built document and return RFC 9457 problem details. HTTP semantics, including method and target parsing, follow RFC 9110. A catalog or session operation that is not in the as-built document is not available to a purchaser. + +Product consequences: + +- `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` are the first as-built catalog family; use a listed `release_ref` and `locale` with `POST /v1/sessions` when that family is available; +- problem details use stable URNs and never echo raw request bodies, SQL, or provider text; +- unpublished instrument rows stay hidden so a purchaser cannot discover draft work through the public catalog. + ## Evidence maintenance rules 1. Review this baseline when a referenced standard is revised, withdrawn, superseded, or materially amended. @@ -124,3 +134,9 @@ Temoshok, D., Proud-Madruga, D., Choong, Y.-Y., Galluzzo, R., Gupta, S., LaSalle World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, 12 December 2024). https://www.w3.org/TR/WCAG22/ World Wide Web Consortium. (2013). *PROV-DM: The PROV data model* (W3C Recommendation, 30 April 2013). https://www.w3.org/TR/prov-dm/ + +Internet Engineering Task Force. (2022). *HTTP Semantics* (RFC 9110). https://www.rfc-editor.org/rfc/rfc9110 + +Internet Engineering Task Force. (2023). *Problem Details for HTTP APIs* (RFC 9457). https://www.rfc-editor.org/rfc/rfc9457 + +OpenAPI Initiative. (2025). *OpenAPI Specification Version 3.2.0*. https://spec.openapis.org/oas/v3.2.0 diff --git a/openapi/instruments.yaml b/openapi/instruments.yaml new file mode 100644 index 00000000..a8ca6446 --- /dev/null +++ b/openapi/instruments.yaml @@ -0,0 +1,139 @@ +openapi: 3.2.0 +info: + title: Psychometrics Commons public instrument catalog API + version: 0.1.0 + description: > + As-built public catalog of startable published instrument releases. A + purchaser lists exact locale-specific releases, then uses release_ref and + locale with POST /v1/sessions. Draft, suspended, and retired releases are + not listed. This family does not create sessions or persist catalog rows. +paths: + /v1/instruments: + get: + operationId: listStartableInstruments + summary: List startable published releases + description: > + Returns published locale-specific releases that currently accept new + sessions, ordered by instrument_ref, locale, then release_ref. Use a + listed release_ref and locale when calling POST /v1/sessions. + responses: + "200": + description: Startable published releases in this process catalog. + content: + application/json: + schema: + $ref: "#/components/schemas/InstrumentReleaseList" + "400": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "405": + $ref: "#/components/responses/Problem" + /v1/instruments/{instrument_ref}: + get: + operationId: getStartableInstrumentFamily + summary: List startable releases for one instrument family + description: > + Returns published releases for one opaque instrument family. Families + that exist only as draft, suspended, or retired rows return 404 so + unpublished work stays hidden. Use a listed release_ref and locale + with POST /v1/sessions. + parameters: + - name: instrument_ref + in: path + required: true + schema: + type: string + description: Opaque instrument family reference. + responses: + "200": + description: Published releases for this family. + content: + application/json: + schema: + $ref: "#/components/schemas/InstrumentFamily" + "400": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "405": + $ref: "#/components/responses/Problem" +components: + schemas: + InstrumentReleaseList: + type: object + additionalProperties: false + required: + - releases + properties: + releases: + type: array + items: + $ref: "#/components/schemas/StartableRelease" + InstrumentFamily: + type: object + additionalProperties: false + required: + - instrument_ref + - releases + properties: + instrument_ref: + type: string + releases: + type: array + items: + $ref: "#/components/schemas/StartableRelease" + StartableRelease: + type: object + additionalProperties: false + required: + - instrument_ref + - release_ref + - instrument_version_ref + - locale + - construct_ref + - content_digest + - intended_use_ref + - limitations_ref + - state + properties: + instrument_ref: + type: string + release_ref: + type: string + instrument_version_ref: + type: string + locale: + type: string + construct_ref: + type: string + content_digest: + type: string + intended_use_ref: + type: string + limitations_ref: + type: string + state: + type: string + enum: + - published + Problem: + type: object + additionalProperties: false + required: [type, title, status, detail] + properties: + type: + type: string + title: + type: string + status: + type: integer + detail: + type: string + responses: + Problem: + description: Safe RFC 9457 problem details without raw request or store errors. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" diff --git a/src/instrument_http.rs b/src/instrument_http.rs new file mode 100644 index 00000000..aec7126e --- /dev/null +++ b/src/instrument_http.rs @@ -0,0 +1,442 @@ +//! Public HTTP transport for listing startable published instrument releases. +//! +//! This slice exposes `GET /v1/instruments` and +//! `GET /v1/instruments/{instrument_ref}` over HTTP/1.1. Only releases whose +//! publication state currently accepts new sessions are visible. Draft, +//! suspended, and retired rows stay hidden so unpublished catalog work cannot +//! be discovered here. A purchaser uses the returned `release_ref` and `locale` +//! with `POST /v1/sessions`. Persistence and session HTTP remain other slices. +//! Errors use RFC 9457 problem details and never echo raw request bodies. + +use crate::instrument::{InstrumentRelease, PublicationState}; +use crate::reference::normalized_reference; +use std::fmt::Write; +use std::io::{self, Read, Write as IoWrite}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; + +/// Public collection path for startable instrument releases. +pub const INSTRUMENT_COLLECTION_PATH: &str = "/v1/instruments"; +/// Bounded read/write timeout for one accepted instrument HTTP connection. +pub const INSTRUMENT_HTTP_IO_TIMEOUT: Duration = Duration::from_secs(2); +/// Maximum accepted instrument HTTP request size, including headers. +pub const INSTRUMENT_HTTP_MAX_REQUEST_BYTES: usize = 4_096; + +/// In-process catalog of instrument releases the handler may disclose. +pub struct InstrumentHttpRuntime { + releases: Vec, +} + +impl InstrumentHttpRuntime { + /// Create a runtime from the exact catalog the handler may inspect. + /// + /// Unpublished releases may be supplied for operator tests; the handler + /// still hides them from public list and family responses. + #[must_use] + pub fn new(releases: Vec) -> Self { + Self { releases } + } + + /// Return how many catalog rows this process currently holds, including hidden ones. + #[must_use] + pub fn catalog_count(&self) -> usize { + self.releases.len() + } +} + +/// HTTP response produced by a public instrument catalog request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstrumentHttpResponse { + status: u16, + content_type: &'static str, + body: String, +} + +impl InstrumentHttpResponse { + fn json(status: u16, body: String) -> Self { + Self { + status, + content_type: "application/json", + body, + } + } + + fn problem(status: u16, type_uri: &str, title: &str, detail: &str) -> Self { + Self { + status, + content_type: "application/problem+json", + body: format!( + "{{\"type\":{},\"title\":{},\"status\":{status},\"detail\":{}}}", + json_string(type_uri), + json_string(title), + json_string(detail) + ), + } + } + + /// Return the HTTP status code. + #[must_use] + pub const fn status(&self) -> u16 { + self.status + } + + /// Return the response content type. + #[must_use] + pub const fn content_type(&self) -> &'static str { + self.content_type + } + + /// Return the response body. + #[must_use] + pub fn body(&self) -> &str { + &self.body + } +} + +/// Translate one raw HTTP/1.1 request into a catalog list or family response. +/// +/// Unknown methods, encoded or numeric instrument references, and families with +/// no startable published release fail closed with RFC 9457 problem details. +#[must_use] +pub fn handle_instrument_http_request( + request: &str, + runtime: &InstrumentHttpRuntime, +) -> InstrumentHttpResponse { + let Some((method, target)) = parse_request_line(request) else { + return InstrumentHttpResponse::problem( + 400, + "urn:psychometrics-commons:problem:bad-request", + "Bad Request", + "instrument request must include an HTTP method and target", + ); + }; + let path = split_target(target).0; + match (method, path) { + ("GET", INSTRUMENT_COLLECTION_PATH) => handle_list(runtime), + ("GET", path) => handle_family(path, runtime), + (_, INSTRUMENT_COLLECTION_PATH) => method_not_allowed(), + (_, path) if path.starts_with("/v1/instruments/") => method_not_allowed(), + _ => InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:not-found", + "Not Found", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ), + } +} + +/// Bind a blocking TCP listener for public instrument HTTP. +/// +/// Tests and local operators typically bind `127.0.0.1:0`. Hosted processes bind +/// `0.0.0.0:$PORT`. This function does not start accepting connections. +/// +/// # Errors +/// +/// Returns the I/O error if the operating system cannot bind the address. +pub fn bind_instrument_http(addr: SocketAddr) -> io::Result { + TcpListener::bind(addr) +} + +/// Accept one TCP connection and serve a single instrument HTTP request. +/// +/// The connection is closed after the response. Keep-alive, TLS, and other +/// public families are outside this slice. +/// +/// # Errors +/// +/// Returns the I/O error if accept, read, or write fails. +pub fn accept_one_instrument_http( + listener: &TcpListener, + runtime: &InstrumentHttpRuntime, +) -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(INSTRUMENT_HTTP_IO_TIMEOUT))?; + stream.set_write_timeout(Some(INSTRUMENT_HTTP_IO_TIMEOUT))?; + let request = read_http_request(&mut stream)?; + let response = handle_instrument_http_request(&request, runtime); + write_http_response(&mut stream, &response) +} + +fn method_not_allowed() -> InstrumentHttpResponse { + InstrumentHttpResponse::problem( + 405, + "urn:psychometrics-commons:problem:method-not-allowed", + "Method Not Allowed", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ) +} + +fn handle_list(runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { + InstrumentHttpResponse::json(200, releases_body(&published_releases(runtime))) +} + +fn handle_family(path: &str, runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { + let Some(instrument_ref) = path + .strip_prefix("/v1/instruments/") + .filter(|value| !value.is_empty() && !value.contains('/')) + else { + return InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:not-found", + "Not Found", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ); + }; + if instrument_ref.contains('%') + || instrument_ref.chars().any(char::is_whitespace) + || normalized_reference(instrument_ref).is_none() + { + return InstrumentHttpResponse::problem( + 400, + "urn:psychometrics-commons:problem:bad-request", + "Bad Request", + "instrument_ref must be an opaque non-numeric family identity", + ); + } + let releases: Vec<&InstrumentRelease> = published_releases(runtime) + .into_iter() + .filter(|release| release.manifest().instrument_ref() == instrument_ref) + .collect(); + if releases.is_empty() { + return InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:instrument-not-found", + "Instrument Not Found", + "Use GET /v1/instruments to list startable published releases, then POST /v1/sessions with a listed release_ref and locale", + ); + } + InstrumentHttpResponse::json( + 200, + format!( + "{{\"instrument_ref\":{},\"releases\":{}}}", + json_string(instrument_ref), + release_array(&releases) + ), + ) +} + +fn published_releases(runtime: &InstrumentHttpRuntime) -> Vec<&InstrumentRelease> { + let mut releases: Vec<&InstrumentRelease> = runtime + .releases + .iter() + .filter(|release| release.state() == PublicationState::Published) + .collect(); + releases.sort_by(|left, right| { + let left_manifest = left.manifest(); + let right_manifest = right.manifest(); + left_manifest + .instrument_ref() + .cmp(right_manifest.instrument_ref()) + .then(left_manifest.locale().cmp(right_manifest.locale())) + .then( + left_manifest + .release_ref() + .cmp(right_manifest.release_ref()), + ) + }); + releases +} + +fn releases_body(releases: &[&InstrumentRelease]) -> String { + format!("{{\"releases\":{}}}", release_array(releases)) +} + +fn release_array(releases: &[&InstrumentRelease]) -> String { + let mut body = String::from("["); + for (index, release) in releases.iter().enumerate() { + if index > 0 { + body.push(','); + } + body.push_str(&release_object(release)); + } + body.push(']'); + body +} + +fn release_object(release: &InstrumentRelease) -> String { + let manifest = release.manifest(); + format!( + "{{\"instrument_ref\":{},\"release_ref\":{},\"instrument_version_ref\":{},\"locale\":{},\"construct_ref\":{},\"content_digest\":{},\"intended_use_ref\":{},\"limitations_ref\":{},\"state\":\"published\"}}", + json_string(manifest.instrument_ref()), + json_string(manifest.release_ref()), + json_string(manifest.instrument_version_ref()), + json_string(manifest.locale()), + json_string(manifest.construct_ref()), + json_string(manifest.content_digest()), + json_string(manifest.intended_use_ref()), + json_string(manifest.limitations_ref()) + ) +} + +fn parse_request_line(request: &str) -> Option<(&str, &str)> { + let line = request.lines().next()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?; + let target = parts.next()?; + let version = parts.next()?; + if !version.starts_with("HTTP/") || parts.next().is_some() { + return None; + } + Some((method, target)) +} + +fn split_target(target: &str) -> (&str, &str) { + match target.split_once('?') { + Some((path, query)) => (path, query), + None => (target, ""), + } +} + +fn json_string(value: &str) -> String { + let mut encoded = String::from("\""); + for character in value.chars() { + match character { + '"' => encoded.push_str("\\\""), + '\\' => encoded.push_str("\\\\"), + '\n' => encoded.push_str("\\n"), + '\r' => encoded.push_str("\\r"), + '\t' => encoded.push_str("\\t"), + character if character.is_control() => { + let _ = write!(encoded, "\\u{:04x}", u32::from(character)); + } + character => encoded.push(character), + } + } + encoded.push('"'); + encoded +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + _ => "Error", + } +} + +fn read_http_request(stream: &mut TcpStream) -> io::Result { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 512]; + loop { + let read_result = stream.read(&mut chunk); + match apply_request_read(&mut buffer, &chunk, read_result)? { + RequestReadProgress::Continue => {} + RequestReadProgress::Complete => break, + } + } + if buffer.len() > INSTRUMENT_HTTP_MAX_REQUEST_BYTES + || !buffer.windows(4).any(|window| window == b"\r\n\r\n") + { + return Ok(String::new()); + } + Ok(String::from_utf8_lossy(&buffer).into_owned()) +} + +#[derive(Debug)] +enum RequestReadProgress { + Continue, + Complete, +} + +fn apply_request_read( + buffer: &mut Vec, + chunk: &[u8], + read_result: io::Result, +) -> io::Result { + match read_result { + Ok(0) => Ok(RequestReadProgress::Complete), + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") + || buffer.len() > INSTRUMENT_HTTP_MAX_REQUEST_BYTES + { + Ok(RequestReadProgress::Complete) + } else { + Ok(RequestReadProgress::Continue) + } + } + Err(error) + if error.kind() == io::ErrorKind::TimedOut + || error.kind() == io::ErrorKind::WouldBlock => + { + Ok(RequestReadProgress::Complete) + } + Err(error) => Err(error), + } +} + +fn write_http_response( + stream: &mut TcpStream, + response: &InstrumentHttpResponse, +) -> io::Result<()> { + let payload = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + reason_phrase(response.status), + response.content_type, + response.body.len(), + response.body + ); + stream.write_all(payload.as_bytes()) +} + +#[cfg(test)] +mod unit_tests { + use super::{ + apply_request_read, json_string, reason_phrase, split_target, RequestReadProgress, + INSTRUMENT_HTTP_MAX_REQUEST_BYTES, + }; + use std::io::{self, ErrorKind}; + + #[test] + fn helpers_cover_escapes_status_and_read_progress() { + assert_eq!(reason_phrase(200), "OK"); + assert_eq!(reason_phrase(400), "Bad Request"); + assert_eq!(reason_phrase(404), "Not Found"); + assert_eq!(reason_phrase(405), "Method Not Allowed"); + assert_eq!(reason_phrase(418), "Error"); + assert_eq!(json_string("a\"b\\c"), "\"a\\\"b\\\\c\""); + assert_eq!(json_string("a\n\r\t"), "\"a\\n\\r\\t\""); + assert_eq!(json_string("\u{0001}"), "\"\\u0001\""); + assert_eq!( + split_target("/v1/instruments?x=1"), + ("/v1/instruments", "x=1") + ); + assert_eq!(split_target("/v1/instruments"), ("/v1/instruments", "")); + let mut buffer = Vec::new(); + assert!(matches!( + apply_request_read(&mut buffer, b"", Ok(0)).unwrap(), + RequestReadProgress::Complete + )); + assert!(matches!( + apply_request_read(&mut Vec::new(), b"GET", Ok(3)).unwrap(), + RequestReadProgress::Continue + )); + let mut oversized = vec![b'x'; INSTRUMENT_HTTP_MAX_REQUEST_BYTES]; + assert!(matches!( + apply_request_read(&mut oversized, b"y", Ok(1)).unwrap(), + RequestReadProgress::Complete + )); + assert!(matches!( + apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::TimedOut, "timeout")) + ) + .unwrap(), + RequestReadProgress::Complete + )); + assert!(matches!( + apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::WouldBlock, "block")) + ) + .unwrap(), + RequestReadProgress::Complete + )); + assert!(apply_request_read(&mut Vec::new(), b"", Err(io::Error::other("boom"))).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8b586a68..284fa791 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod data_rights; pub mod deterministic_narrative; pub mod health; pub mod instrument; +pub mod instrument_http; pub mod integration; pub mod item_delivery; pub mod narrative; diff --git a/tests/documentation_architecture_contract.rs b/tests/documentation_architecture_contract.rs index 7b5c6fad..86144307 100644 --- a/tests/documentation_architecture_contract.rs +++ b/tests/documentation_architecture_contract.rs @@ -336,6 +336,37 @@ fn erd_covers_current_delivery_identity_and_longitudinal_boundaries() { } } +#[test] +fn instrument_catalog_http_contract_is_mapped() { + let root = repository_root(); + let openapi = read_required(&root.join("openapi/instruments.yaml")); + let traceability = read_required(&root.join("docs/TRACEABILITY.md")); + let changelog = read_required(&root.join("CHANGELOG.md")); + + for marker in [ + "openapi: 3.2.0", + "/v1/instruments", + "/v1/instruments/{instrument_ref}", + "listStartableInstruments", + "getStartableInstrumentFamily", + "application/problem+json", + ] { + assert!( + openapi.contains(marker), + "instrument OpenAPI must name as-built marker {marker}" + ); + } + assert!( + traceability.contains("GET /v1/instruments") + && traceability.contains("not protected-main truth"), + "traceability must keep instrument catalog HTTP off protected main until integrated" + ); + assert!( + changelog.contains("GET /v1/instruments"), + "changelog must tell a purchaser the catalog list path" + ); +} + #[test] fn uml_covers_identity_longitudinal_and_workbench_behavior() { let uml = read_required(&repository_root().join("docs/architecture/UML.md")); diff --git a/tests/instrument_http_contract.rs b/tests/instrument_http_contract.rs new file mode 100644 index 00000000..a04953d0 --- /dev/null +++ b/tests/instrument_http_contract.rs @@ -0,0 +1,334 @@ +//! Public instrument catalog HTTP contract for startable published releases. +//! +//! A purchaser lists the exact locale-specific releases that may begin a new +//! session, then uses `release_ref` and `locale` with `POST /v1/sessions`. +//! Draft, suspended, and retired releases stay hidden so unpublished catalog +//! rows cannot be discovered through this family. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, + InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, +}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +const VALID_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ENGLISH_DIGEST: &str = + "sha256:2222222222222222222222222222222222222222222222222222222222222222"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const ENGLISH_EVIDENCE_DIGEST: &str = + "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + +fn manifest( + release_ref: &str, + instrument_ref: &str, + locale: &str, + digest: &str, +) -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_big_five_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + locale, + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + digest, + ) + .unwrap() +} + +fn approved_evidence( + evidence_ref: &str, + release_ref: &str, + locale: &str, + digest: &str, + evidence_digest: &str, +) -> PublicationEvidenceRecord { + PublicationEvidenceRecord::new( + evidence_ref, + "evidence_policy_self_reflection_v1", + release_ref, + "instrument_version_big_five_v1", + &["item_version_001", "item_version_002"], + digest, + locale, + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + evidence_digest, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_v1"], + &["approval_psychometrics_big_five_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap() +} + +fn publish( + mut release: InstrumentRelease, + evidence: PublicationEvidenceRecord, + reviewed_at_unix_ms: u64, + published_at_unix_ms: u64, +) -> InstrumentRelease { + release + .apply_command( + "publication_review_catalog", + PublicationCommand::SubmitReview, + reviewed_at_unix_ms, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_catalog", + PublicationCommand::Publish, + published_at_unix_ms, + ) + .unwrap(); + release +} + +fn published_korean() -> InstrumentRelease { + publish( + InstrumentRelease::new( + manifest( + "release_big_five_ko_v1", + "instrument_big_five", + "ko-KR", + VALID_DIGEST, + ), + 10_000, + ) + .unwrap(), + approved_evidence( + "publication_evidence_big_five_ko_v1", + "release_big_five_ko_v1", + "ko-KR", + VALID_DIGEST, + EVIDENCE_DIGEST, + ), + 10_100, + 10_200, + ) +} + +fn published_english() -> InstrumentRelease { + publish( + InstrumentRelease::new( + manifest( + "release_big_five_en_v1", + "instrument_big_five", + "en-US", + ENGLISH_DIGEST, + ), + 11_000, + ) + .unwrap(), + approved_evidence( + "publication_evidence_big_five_en_v1", + "release_big_five_en_v1", + "en-US", + ENGLISH_DIGEST, + ENGLISH_EVIDENCE_DIGEST, + ), + 11_100, + 11_200, + ) +} + +fn draft_korean() -> InstrumentRelease { + InstrumentRelease::new( + manifest( + "release_big_five_ko_draft", + "instrument_big_five", + "ko-KR", + VALID_DIGEST, + ), + 10_000, + ) + .unwrap() +} + +fn get_request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn list_returns_only_published_releases_in_stable_order() { + let runtime = InstrumentHttpRuntime::new(vec![ + draft_korean(), + published_english(), + published_korean(), + ]); + let response = + handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &runtime); + + assert_eq!(response.status(), 200); + assert_eq!(response.content_type(), "application/json"); + let body = response.body(); + assert!(body.contains("\"release_ref\":\"release_big_five_en_v1\"")); + assert!(body.contains("\"release_ref\":\"release_big_five_ko_v1\"")); + assert!(body.contains("\"locale\":\"en-US\"")); + assert!(body.contains("\"locale\":\"ko-KR\"")); + assert!(body.contains("\"instrument_ref\":\"instrument_big_five\"")); + assert!(body.contains(&format!("\"content_digest\":\"{VALID_DIGEST}\""))); + assert!(body.contains("\"state\":\"published\"")); + assert!( + !body.contains("release_big_five_ko_draft"), + "unpublished releases must stay hidden from the public catalog" + ); + let english = body + .find("release_big_five_en_v1") + .expect("english release"); + let korean = body.find("release_big_five_ko_v1").expect("korean release"); + assert!( + english < korean, + "catalog order must be locale then release_ref so a purchaser can pick ko-KR or en-US predictably" + ); +} + +#[test] +fn get_family_returns_published_releases_and_hides_unknown_or_draft_only_families() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean(), draft_korean()]); + let found = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_big_five"), + &runtime, + ); + assert_eq!(found.status(), 200); + assert!(found + .body() + .contains("\"instrument_ref\":\"instrument_big_five\"")); + assert!(found + .body() + .contains("\"release_ref\":\"release_big_five_ko_v1\"")); + assert!(!found.body().contains("release_big_five_ko_draft")); + + let missing = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_missing_family"), + &runtime, + ); + assert_eq!(missing.status(), 404); + assert_eq!(missing.content_type(), "application/problem+json"); + assert!(missing + .body() + .contains("urn:psychometrics-commons:problem:instrument-not-found")); + assert!(missing + .body() + .contains("Use GET /v1/instruments to list startable published releases")); +} + +#[test] +fn invalid_paths_and_methods_fail_closed_without_leaking_catalog_rows() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + + let numeric = handle_instrument_http_request(&get_request("/v1/instruments/42"), &runtime); + assert_eq!(numeric.status(), 400); + assert!(numeric + .body() + .contains("urn:psychometrics-commons:problem:bad-request")); + + let padded = handle_instrument_http_request( + &get_request("/v1/instruments/%20instrument_big_five"), + &runtime, + ); + assert_eq!(padded.status(), 400); + + let post = handle_instrument_http_request( + "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(post.status(), 405); + assert!(post + .body() + .contains("urn:psychometrics-commons:problem:method-not-allowed")); + + let other = handle_instrument_http_request(&get_request("/v1/sessions"), &runtime); + assert_eq!(other.status(), 404); + + let nested = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_big_five/extra"), + &runtime, + ); + assert_eq!(nested.status(), 404); + + let put_family = handle_instrument_http_request( + "PUT /v1/instruments/instrument_big_five HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(put_family.status(), 405); + + let malformed = handle_instrument_http_request("not-an-http-request", &runtime); + assert_eq!(malformed.status(), 400); + assert_eq!(runtime.catalog_count(), 1); +} + +#[test] +fn empty_catalog_and_unrelated_methods_stay_safe() { + let empty = InstrumentHttpRuntime::new(Vec::new()); + let listed = handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &empty); + assert_eq!(listed.status(), 200); + assert_eq!(listed.body(), "{\"releases\":[]}"); + + let queried = + handle_instrument_http_request(&get_request("/v1/instruments?locale=ko-KR"), &empty); + assert_eq!(queried.status(), 200); + assert_eq!(queried.body(), "{\"releases\":[]}"); + + let unrelated = handle_instrument_http_request( + "POST /v1/sessions HTTP/1.1\r\nHost: localhost\r\n\r\n", + &empty, + ); + assert_eq!(unrelated.status(), 404); +} + +#[test] +fn listener_serves_one_published_catalog_request() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .write_all(b"GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut body = String::new(); + client.read_to_string(&mut body).unwrap(); + server.join().unwrap().unwrap(); + + assert!(body.starts_with("HTTP/1.1 200 OK")); + assert!(body.contains("release_big_five_ko_v1")); + assert!(body.contains("application/json")); +} From 02d9121b40502866d4efae8c2e567bcaedb8c898 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:08:18 +0000 Subject: [PATCH 02/22] docs(traceability): name Active PR #165 for instrument catalog HTTP Keep the catalog family off protected main until an unchanged reviewed head is integrated, and keep it separate from #149, #150, and #98. Co-authored-by: Seongho Bae --- docs/DOCUMENTATION_ASSESSMENT.md | 2 +- docs/TRACEABILITY.md | 2 +- docs/adr/0014-api-and-event-contract-representation.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/DOCUMENTATION_ASSESSMENT.md b/docs/DOCUMENTATION_ASSESSMENT.md index 4b58cf82..5affa212 100644 --- a/docs/DOCUMENTATION_ASSESSMENT.md +++ b/docs/DOCUMENTATION_ASSESSMENT.md @@ -31,7 +31,7 @@ This reconciliation closes those architecture-definition gaps without promoting | Quality / risk / compliance readiness | **Sufficient as assurance baseline** | Evidence scenarios and risk state are explicit; readiness is not certification. | | Traceability | **Repaired in this reconciliation** | Baseline now names exact protected-main `748876…`, marks newly merged domain modules Implemented/Partial, and isolates PR #24 as Active PR. Must be updated after every material merge. | | Roadmap / agent guidance / changelog | **Sufficient for continued delivery** | Must remain code-current; documentation completion is not a terminal condition for the execution loop. | -| Machine-readable OpenAPI / AsyncAPI | **Active PR** instrument catalog HTTP (`openapi/instruments.yaml`) is as-built for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; remaining families and AsyncAPI stay unimplemented | Do not list session, response, consent, or event operations as deployed until those transports exist. | +| Machine-readable OpenAPI / AsyncAPI | **Active PR** #165 instrument catalog HTTP (`openapi/instruments.yaml`) is as-built for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; remaining families and AsyncAPI stay unimplemented | Do not list session, response, consent, or event operations as deployed until those transports exist. | | Physical schema / as-built topology | **Partial / implementation-gated** | Logical ERD is authoritative target semantics. Actual migrations/topology/rollback/restore evidence must be compared to it as those artifacts land. | | Instrument-release evidence bundles | **Target** | Every publishable consumer instrument needs immutable rights, locale/translation, scoring/calibration/norm, DIF/invariance/linking where claimed, scoreability, intended-use and narrative-rule evidence. | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 7246bc7c..47bf54c3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** instrument catalog HTTP is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases from an in-process catalog so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. Session create/reload, persistence, and other public families remain outside this slice. +**Active PR** #165 instrument catalog HTTP is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases from an in-process catalog so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. Do not mix onto #149 session HTTP, #150 research persistence, or #98 instrument load. Session create/reload, persistence, and other public families remain outside this slice. ## 5. ADR traceability by concern diff --git a/docs/adr/0014-api-and-event-contract-representation.md b/docs/adr/0014-api-and-event-contract-representation.md index 3e78815d..ea7eb945 100644 --- a/docs/adr/0014-api-and-event-contract-representation.md +++ b/docs/adr/0014-api-and-event-contract-representation.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons public/admin HTTP APIs, product-owned durable domain events, errors, schema/version negotiation - Supersedes: none - Superseded by: none -- Current/as-built status: public/admin HTTP transport and durable external event transport are not yet implemented on protected main; current Rust domain contracts are transport-neutral. Active PR instrument catalog HTTP adds as-built OpenAPI 3.2.0 for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; that slice is not protected-main truth until an unchanged reviewed/check-clean head is integrated. +- Current/as-built status: public/admin HTTP transport and durable external event transport are not yet implemented on protected main; current Rust domain contracts are transport-neutral. Active PR #165 instrument catalog HTTP adds as-built OpenAPI 3.2.0 for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; that slice is not protected-main truth until an unchanged reviewed/check-clean head is integrated. - Target status: every implemented HTTP/event surface has an exact versioned machine-readable as-built contract and deterministic integrity/idempotency semantics - Migration status: no deployed HTTP/event transport requires migration yet; the first implementation must introduce the contract in the same or prerequisite PR From 523ceb73c0da45d9a85ddd04b56634d99929e3e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:44:14 +0900 Subject: [PATCH 03/22] test(recovery): seed processing claim_deadline_at after inbox expiry Protected-main #81 requires claim_deadline_at on processing integration_consumption rows. The catalog HTTP head still used the pre-#81 fixture and failed constraint integration_consumption_claim_deadline_shape. --- tests/postgres_recovery_invariants.rs | 30 ++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/postgres_recovery_invariants.rs b/tests/postgres_recovery_invariants.rs index e8af1d61..b97d15f8 100644 --- a/tests/postgres_recovery_invariants.rs +++ b/tests/postgres_recovery_invariants.rs @@ -84,11 +84,11 @@ fn seed_recovery_critical_state(client: &mut Client) { INSERT INTO {SOURCE_SCHEMA}.integration_consumption ( consumer_ref, source_ref, tenant_ref, source_event_ref, consumption_ref, side_effect_ref, consumption_state, fencing_token, latest_event_at_unix_ms, - claim_expires_at_unix_ms, completion_evidence_ref, cause_code + claim_expires_at_unix_ms, claim_deadline_at, completion_evidence_ref, cause_code ) VALUES ( 'consumer_recovery_alpha', 'dependency_recovery_alpha', 'tenant_recovery_alpha', 'event_dependency_alpha', 'consumption_recovery_alpha', 'effect_recovery_alpha', - 'processing', 7, 12000, 13000, NULL, NULL + 'processing', 7, 12000, 13000, clock_timestamp() + INTERVAL '1 hour', NULL, NULL ); INSERT INTO {SOURCE_SCHEMA}.response_snapshot ( snapshot_ref, session_ref, event_count, last_sequence @@ -150,7 +150,8 @@ fn assert_restored_evidence(client: &mut Client) { let restored_consumption = client .query_one( &format!( - "SELECT consumption_state, fencing_token, claim_expires_at_unix_ms + "SELECT consumption_state, fencing_token, claim_expires_at_unix_ms, + claim_deadline_at IS NOT NULL FROM {RESTORED_SCHEMA}.integration_consumption WHERE consumption_ref = 'consumption_recovery_alpha'" ), @@ -160,6 +161,29 @@ fn assert_restored_evidence(client: &mut Client) { assert_eq!(restored_consumption.get::<_, String>(0), "processing"); assert_eq!(restored_consumption.get::<_, i64>(1), 7); assert_eq!(restored_consumption.get::<_, Option>(2), Some(13000)); + assert!(restored_consumption.get::<_, bool>(3)); + + let claim_deadline_matches_source: bool = client + .query_one( + &format!( + "SELECT source_row.claim_deadline_at = restored_row.claim_deadline_at + FROM {SOURCE_SCHEMA}.integration_consumption AS source_row + JOIN {RESTORED_SCHEMA}.integration_consumption AS restored_row + ON restored_row.consumer_ref = source_row.consumer_ref + AND restored_row.source_ref = source_row.source_ref + AND restored_row.tenant_ref = source_row.tenant_ref + AND restored_row.source_event_ref = source_row.source_event_ref + AND restored_row.consumption_ref = source_row.consumption_ref + WHERE source_row.consumption_ref = 'consumption_recovery_alpha'" + ), + &[], + ) + .expect("restored claim-deadline evidence should remain comparable") + .get(0); + assert!( + claim_deadline_matches_source, + "restore must preserve the exact database-authoritative processing claim deadline" + ); let restored_snapshot = client .query_one( From 336c1edafd567af85d351399fe947799e60d8667 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:33:21 +0900 Subject: [PATCH 04/22] test(api): cover truncated catalog requests and extra request-line tokens Production coverage missed parse_request_line's extra-token arm and the empty-buffer fail-closed return when a client closes before headers end. --- src/instrument_http.rs | 6 ++-- tests/instrument_http_contract.rs | 50 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index aec7126e..4343703e 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -385,8 +385,8 @@ fn write_http_response( #[cfg(test)] mod unit_tests { use super::{ - apply_request_read, json_string, reason_phrase, split_target, RequestReadProgress, - INSTRUMENT_HTTP_MAX_REQUEST_BYTES, + apply_request_read, json_string, parse_request_line, reason_phrase, split_target, + RequestReadProgress, INSTRUMENT_HTTP_MAX_REQUEST_BYTES, }; use std::io::{self, ErrorKind}; @@ -438,5 +438,7 @@ mod unit_tests { RequestReadProgress::Complete )); assert!(apply_request_read(&mut Vec::new(), b"", Err(io::Error::other("boom"))).is_err()); + assert!(parse_request_line("GET /v1/instruments SMTP/1.0\r\n\r\n").is_none()); + assert!(parse_request_line("GET /v1/instruments HTTP/1.1 leftover\r\n\r\n").is_none()); } } diff --git a/tests/instrument_http_contract.rs b/tests/instrument_http_contract.rs index a04953d0..3a7f0fd7 100644 --- a/tests/instrument_http_contract.rs +++ b/tests/instrument_http_contract.rs @@ -332,3 +332,53 @@ fn listener_serves_one_published_catalog_request() { assert!(body.contains("release_big_five_ko_v1")); assert!(body.contains("application/json")); } + +#[test] +fn listener_fails_closed_for_truncated_and_extra_token_requests() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn({ + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + move || { + accept_one_instrument_http(&listener, &runtime).unwrap(); + accept_one_instrument_http(&listener, &runtime).unwrap(); + } + }); + + let mut truncated = TcpStream::connect(address).unwrap(); + truncated + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + truncated.write_all(b"GET /v1/instruments").unwrap(); + truncated.shutdown(std::net::Shutdown::Write).unwrap(); + let mut truncated_body = String::new(); + truncated.read_to_string(&mut truncated_body).unwrap(); + assert!( + truncated_body.starts_with("HTTP/1.1 400 Bad Request"), + "{truncated_body}" + ); + + let extra = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1 leftover\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(extra.status(), 400); + + let mut extra_wire = TcpStream::connect(address).unwrap(); + extra_wire + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + extra_wire + .write_all(b"GET /v1/instruments HTTP/1.1 leftover\r\nHost: localhost\r\n\r\n") + .unwrap(); + extra_wire.shutdown(std::net::Shutdown::Write).unwrap(); + let mut extra_body = String::new(); + extra_wire.read_to_string(&mut extra_body).unwrap(); + assert!( + extra_body.starts_with("HTTP/1.1 400 Bad Request"), + "{extra_body}" + ); + + server.join().unwrap(); +} From e214a57003932996985b8faff6bc2561cafb5f1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:22:14 +0900 Subject: [PATCH 05/22] test(api): hide suspended and unpublished instrument families --- .../instrument_http_publication_visibility.rs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/instrument_http_publication_visibility.rs diff --git a/tests/instrument_http_publication_visibility.rs b/tests/instrument_http_publication_visibility.rs new file mode 100644 index 00000000..6d46d70a --- /dev/null +++ b/tests/instrument_http_publication_visibility.rs @@ -0,0 +1,166 @@ +//! Regression coverage for public instrument-catalog publication visibility. +//! +//! The public catalog is an authorization boundary for starting assessments: +//! only exactly `Published` releases may be discoverable. Draft and suspended +//! families must remain indistinguishable from a missing family. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +fn manifest(release_ref: &str, instrument_ref: &str) -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_catalog_visibility_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap() +} + +fn approved_evidence(release_ref: &str, evidence_ref: &str) -> PublicationEvidenceRecord { + PublicationEvidenceRecord::new( + evidence_ref, + "evidence_policy_catalog_visibility_v1", + release_ref, + "instrument_version_catalog_visibility_v1", + &["item_version_001", "item_version_002"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_v1"], + &["approval_psychometrics_big_five_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap() +} + +fn published_release(release_ref: &str, instrument_ref: &str, evidence_ref: &str) -> InstrumentRelease { + let mut release = InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); + release + .apply_command( + &format!("publication_review_{release_ref}"), + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release + .bind_publication_evidence(approved_evidence(release_ref, evidence_ref)) + .unwrap(); + release + .apply_command( + &format!("publication_publish_{release_ref}"), + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn get_request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn collection_exposes_only_exactly_published_releases() { + let visible = published_release( + "release_visible_published", + "instrument_visible_family", + "publication_evidence_visible", + ); + let mut suspended = published_release( + "release_hidden_suspended", + "instrument_suspended_family", + "publication_evidence_suspended", + ); + suspended + .apply_command( + "publication_suspend_hidden", + PublicationCommand::Suspend, + 10_300, + ) + .unwrap(); + let draft = InstrumentRelease::new( + manifest("release_hidden_draft", "instrument_draft_family"), + 10_000, + ) + .unwrap(); + + let runtime = InstrumentHttpRuntime::new(vec![draft, suspended, visible]); + let response = + handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &runtime); + + assert_eq!(response.status(), 200); + assert!(response.body().contains("release_visible_published")); + assert!(!response.body().contains("release_hidden_suspended")); + assert!(!response.body().contains("release_hidden_draft")); +} + +#[test] +fn unpublished_only_families_are_indistinguishable_from_missing_families() { + let mut suspended = published_release( + "release_hidden_suspended", + "instrument_suspended_family", + "publication_evidence_suspended", + ); + suspended + .apply_command( + "publication_suspend_hidden", + PublicationCommand::Suspend, + 10_300, + ) + .unwrap(); + let draft = InstrumentRelease::new( + manifest("release_hidden_draft", "instrument_draft_family"), + 10_000, + ) + .unwrap(); + let runtime = InstrumentHttpRuntime::new(vec![draft, suspended]); + + for family in ["instrument_draft_family", "instrument_suspended_family"] { + let response = handle_instrument_http_request( + &get_request(&format!("/v1/instruments/{family}")), + &runtime, + ); + assert_eq!(response.status(), 404, "{family} must stay undiscoverable"); + assert_eq!(response.content_type(), "application/problem+json"); + assert!(response + .body() + .contains("urn:psychometrics-commons:problem:instrument-not-found")); + } +} From a3d3a677de2f71a302a704a2b3a4665a0405261f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:23:41 +0900 Subject: [PATCH 06/22] style(api): keep catalog visibility regression rustfmt-clean --- tests/instrument_http_publication_visibility.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/instrument_http_publication_visibility.rs b/tests/instrument_http_publication_visibility.rs index 6d46d70a..6232940b 100644 --- a/tests/instrument_http_publication_visibility.rs +++ b/tests/instrument_http_publication_visibility.rs @@ -70,7 +70,11 @@ fn approved_evidence(release_ref: &str, evidence_ref: &str) -> PublicationEviden .unwrap() } -fn published_release(release_ref: &str, instrument_ref: &str, evidence_ref: &str) -> InstrumentRelease { +fn published_release( + release_ref: &str, + instrument_ref: &str, + evidence_ref: &str, +) -> InstrumentRelease { let mut release = InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); release .apply_command( From f2f6f83b6a2acd70a6309fdacb75a6908c3a6103 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:49:33 +0900 Subject: [PATCH 07/22] style(api): rustfmt catalog visibility regression Linux cargo fmt --check failed on the new unpublished-family test. --- tests/instrument_http_publication_visibility.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/instrument_http_publication_visibility.rs b/tests/instrument_http_publication_visibility.rs index 6232940b..cd0aa117 100644 --- a/tests/instrument_http_publication_visibility.rs +++ b/tests/instrument_http_publication_visibility.rs @@ -75,7 +75,8 @@ fn published_release( instrument_ref: &str, evidence_ref: &str, ) -> InstrumentRelease { - let mut release = InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); + let mut release = + InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); release .apply_command( &format!("publication_review_{release_ref}"), From 8a6559520cad8046cafc6cafc86d49abf45452e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:08:02 -0700 Subject: [PATCH 08/22] test(instrument-http): cover every non-startable publication state --- .../instrument_http_publication_visibility.rs | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/tests/instrument_http_publication_visibility.rs b/tests/instrument_http_publication_visibility.rs index cd0aa117..195439c5 100644 --- a/tests/instrument_http_publication_visibility.rs +++ b/tests/instrument_http_publication_visibility.rs @@ -1,8 +1,9 @@ //! Regression coverage for public instrument-catalog publication visibility. //! //! The public catalog is an authorization boundary for starting assessments: -//! only exactly `Published` releases may be discoverable. Draft and suspended -//! families must remain indistinguishable from a missing family. +//! only exactly `Published` releases may be discoverable. Draft, review, +//! suspended, and retired families must remain indistinguishable from a missing +//! family. use psychometrics_commons_runtime::instrument::{ InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, @@ -70,11 +71,7 @@ fn approved_evidence(release_ref: &str, evidence_ref: &str) -> PublicationEviden .unwrap() } -fn published_release( - release_ref: &str, - instrument_ref: &str, - evidence_ref: &str, -) -> InstrumentRelease { +fn review_release(release_ref: &str, instrument_ref: &str) -> InstrumentRelease { let mut release = InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); release @@ -85,6 +82,15 @@ fn published_release( ) .unwrap(); release +} + +fn published_release( + release_ref: &str, + instrument_ref: &str, + evidence_ref: &str, +) -> InstrumentRelease { + let mut release = review_release(release_ref, instrument_ref); + release .bind_publication_evidence(approved_evidence(release_ref, evidence_ref)) .unwrap(); release @@ -108,6 +114,7 @@ fn collection_exposes_only_exactly_published_releases() { "instrument_visible_family", "publication_evidence_visible", ); + let review = review_release("release_hidden_review", "instrument_review_family"); let mut suspended = published_release( "release_hidden_suspended", "instrument_suspended_family", @@ -120,24 +127,39 @@ fn collection_exposes_only_exactly_published_releases() { 10_300, ) .unwrap(); + let mut retired = published_release( + "release_hidden_retired", + "instrument_retired_family", + "publication_evidence_retired", + ); + retired + .apply_command( + "publication_retire_hidden", + PublicationCommand::Retire, + 10_300, + ) + .unwrap(); let draft = InstrumentRelease::new( manifest("release_hidden_draft", "instrument_draft_family"), 10_000, ) .unwrap(); - let runtime = InstrumentHttpRuntime::new(vec![draft, suspended, visible]); + let runtime = InstrumentHttpRuntime::new(vec![draft, review, suspended, retired, visible]); let response = handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &runtime); assert_eq!(response.status(), 200); assert!(response.body().contains("release_visible_published")); - assert!(!response.body().contains("release_hidden_suspended")); assert!(!response.body().contains("release_hidden_draft")); + assert!(!response.body().contains("release_hidden_review")); + assert!(!response.body().contains("release_hidden_suspended")); + assert!(!response.body().contains("release_hidden_retired")); } #[test] fn unpublished_only_families_are_indistinguishable_from_missing_families() { + let review = review_release("release_hidden_review", "instrument_review_family"); let mut suspended = published_release( "release_hidden_suspended", "instrument_suspended_family", @@ -150,14 +172,31 @@ fn unpublished_only_families_are_indistinguishable_from_missing_families() { 10_300, ) .unwrap(); + let mut retired = published_release( + "release_hidden_retired", + "instrument_retired_family", + "publication_evidence_retired", + ); + retired + .apply_command( + "publication_retire_hidden", + PublicationCommand::Retire, + 10_300, + ) + .unwrap(); let draft = InstrumentRelease::new( manifest("release_hidden_draft", "instrument_draft_family"), 10_000, ) .unwrap(); - let runtime = InstrumentHttpRuntime::new(vec![draft, suspended]); + let runtime = InstrumentHttpRuntime::new(vec![draft, review, suspended, retired]); - for family in ["instrument_draft_family", "instrument_suspended_family"] { + for family in [ + "instrument_draft_family", + "instrument_review_family", + "instrument_suspended_family", + "instrument_retired_family", + ] { let response = handle_instrument_http_request( &get_request(&format!("/v1/instruments/{family}")), &runtime, From 1900701f53fac52ea371edc0e54c83c026134ad8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:10:38 -0700 Subject: [PATCH 09/22] test(instrument-http): require RFC 9110 Allow metadata --- tests/instrument_http_allow_contract.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/instrument_http_allow_contract.rs diff --git a/tests/instrument_http_allow_contract.rs b/tests/instrument_http_allow_contract.rs new file mode 100644 index 00000000..48411348 --- /dev/null +++ b/tests/instrument_http_allow_contract.rs @@ -0,0 +1,59 @@ +//! RFC 9110 method-rejection contract for the public instrument catalog. +//! +//! A 405 response must expose the allowed method to both in-process embedding +//! hosts and the HTTP/1.1 wire serializer. Ordinary responses must not invent an +//! `Allow` field. + +use psychometrics_commons_runtime::instrument_http::{ + accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, + InstrumentHttpRuntime, +}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[test] +fn method_rejection_exposes_get_as_the_allowed_method() { + let runtime = InstrumentHttpRuntime::new(Vec::new()); + + let rejected = handle_instrument_http_request( + "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(rejected.status(), 405); + assert_eq!(rejected.allow(), Some("GET")); + + let listed = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(listed.status(), 200); + assert_eq!(listed.allow(), None); +} + +#[test] +fn socket_serialization_emits_allow_only_for_405() { + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let runtime = InstrumentHttpRuntime::new(Vec::new()); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .write_all(b"POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + server.join().unwrap().unwrap(); + + assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed")); + assert!(response.contains("\r\nAllow: GET\r\n"), "{response}"); +} From 123aee66ff8af7ef067584f10982dcc10505b8ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:13:37 -0700 Subject: [PATCH 10/22] chore(instrument-http): remove unimplemented Allow probe --- tests/instrument_http_allow_contract.rs | 59 ------------------------- 1 file changed, 59 deletions(-) delete mode 100644 tests/instrument_http_allow_contract.rs diff --git a/tests/instrument_http_allow_contract.rs b/tests/instrument_http_allow_contract.rs deleted file mode 100644 index 48411348..00000000 --- a/tests/instrument_http_allow_contract.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! RFC 9110 method-rejection contract for the public instrument catalog. -//! -//! A 405 response must expose the allowed method to both in-process embedding -//! hosts and the HTTP/1.1 wire serializer. Ordinary responses must not invent an -//! `Allow` field. - -use psychometrics_commons_runtime::instrument_http::{ - accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, - InstrumentHttpRuntime, -}; -use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpStream}; -use std::time::Duration; - -#[test] -fn method_rejection_exposes_get_as_the_allowed_method() { - let runtime = InstrumentHttpRuntime::new(Vec::new()); - - let rejected = handle_instrument_http_request( - "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", - &runtime, - ); - assert_eq!(rejected.status(), 405); - assert_eq!(rejected.allow(), Some("GET")); - - let listed = handle_instrument_http_request( - "GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", - &runtime, - ); - assert_eq!(listed.status(), 200); - assert_eq!(listed.allow(), None); -} - -#[test] -fn socket_serialization_emits_allow_only_for_405() { - let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); - let address = listener.local_addr().unwrap(); - let runtime = InstrumentHttpRuntime::new(Vec::new()); - let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); - - let mut client = TcpStream::connect(address).unwrap(); - client - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - client - .set_write_timeout(Some(Duration::from_secs(2))) - .unwrap(); - client - .write_all(b"POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") - .unwrap(); - client.shutdown(std::net::Shutdown::Write).unwrap(); - - let mut response = String::new(); - client.read_to_string(&mut response).unwrap(); - server.join().unwrap().unwrap(); - - assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed")); - assert!(response.contains("\r\nAllow: GET\r\n"), "{response}"); -} From 565cb5cf29f7f3b4f70337dfce0af6d08f063db5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:19:28 -0700 Subject: [PATCH 11/22] test(instrument-http): require RFC 9110 Allow metadata --- tests/instrument_http_allow_contract.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/instrument_http_allow_contract.rs diff --git a/tests/instrument_http_allow_contract.rs b/tests/instrument_http_allow_contract.rs new file mode 100644 index 00000000..48411348 --- /dev/null +++ b/tests/instrument_http_allow_contract.rs @@ -0,0 +1,59 @@ +//! RFC 9110 method-rejection contract for the public instrument catalog. +//! +//! A 405 response must expose the allowed method to both in-process embedding +//! hosts and the HTTP/1.1 wire serializer. Ordinary responses must not invent an +//! `Allow` field. + +use psychometrics_commons_runtime::instrument_http::{ + accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, + InstrumentHttpRuntime, +}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[test] +fn method_rejection_exposes_get_as_the_allowed_method() { + let runtime = InstrumentHttpRuntime::new(Vec::new()); + + let rejected = handle_instrument_http_request( + "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(rejected.status(), 405); + assert_eq!(rejected.allow(), Some("GET")); + + let listed = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(listed.status(), 200); + assert_eq!(listed.allow(), None); +} + +#[test] +fn socket_serialization_emits_allow_only_for_405() { + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let runtime = InstrumentHttpRuntime::new(Vec::new()); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .write_all(b"POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + server.join().unwrap().unwrap(); + + assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed")); + assert!(response.contains("\r\nAllow: GET\r\n"), "{response}"); +} From e27d6deb3f75f15d9b03559e1d61553eb5d96402 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:20:47 -0700 Subject: [PATCH 12/22] fix(instrument-http): emit RFC 9110 Allow for method rejection --- src/instrument_http.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index 4343703e..3c8d57f3 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -49,6 +49,7 @@ impl InstrumentHttpRuntime { pub struct InstrumentHttpResponse { status: u16, content_type: &'static str, + allow: Option<&'static str>, body: String, } @@ -57,6 +58,7 @@ impl InstrumentHttpResponse { Self { status, content_type: "application/json", + allow: None, body, } } @@ -65,6 +67,7 @@ impl InstrumentHttpResponse { Self { status, content_type: "application/problem+json", + allow: None, body: format!( "{{\"type\":{},\"title\":{},\"status\":{status},\"detail\":{}}}", json_string(type_uri), @@ -86,6 +89,12 @@ impl InstrumentHttpResponse { self.content_type } + /// Return the RFC 9110 `Allow` field when the request method is rejected. + #[must_use] + pub const fn allow(&self) -> Option<&'static str> { + self.allow + } + /// Return the response body. #[must_use] pub fn body(&self) -> &str { @@ -158,12 +167,14 @@ pub fn accept_one_instrument_http( } fn method_not_allowed() -> InstrumentHttpResponse { - InstrumentHttpResponse::problem( + let mut response = InstrumentHttpResponse::problem( 405, "urn:psychometrics-commons:problem:method-not-allowed", "Method Not Allowed", "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", - ) + ); + response.allow = Some("GET"); + response } fn handle_list(runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { @@ -371,11 +382,15 @@ fn write_http_response( stream: &mut TcpStream, response: &InstrumentHttpResponse, ) -> io::Result<()> { + let allow_header = response + .allow + .map_or_else(String::new, |value| format!("Allow: {value}\r\n")); let payload = format!( - "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\n{}Content-Length: {}\r\nConnection: close\r\n\r\n{}", response.status, reason_phrase(response.status), response.content_type, + allow_header, response.body.len(), response.body ); From 270bcb20526caa1f71834e364e93d9b2abda860b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:21:42 -0700 Subject: [PATCH 13/22] docs(instrument-http): bind 405 Allow contract in OpenAPI --- openapi/instruments.yaml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openapi/instruments.yaml b/openapi/instruments.yaml index a8ca6446..8c260da0 100644 --- a/openapi/instruments.yaml +++ b/openapi/instruments.yaml @@ -28,7 +28,7 @@ paths: "404": $ref: "#/components/responses/Problem" "405": - $ref: "#/components/responses/Problem" + $ref: "#/components/responses/MethodNotAllowed" /v1/instruments/{instrument_ref}: get: operationId: getStartableInstrumentFamily @@ -57,7 +57,7 @@ paths: "404": $ref: "#/components/responses/Problem" "405": - $ref: "#/components/responses/Problem" + $ref: "#/components/responses/MethodNotAllowed" components: schemas: InstrumentReleaseList: @@ -137,3 +137,15 @@ components: application/problem+json: schema: $ref: "#/components/schemas/Problem" + MethodNotAllowed: + description: The public instrument catalog only implements GET. + headers: + Allow: + description: RFC 9110 method set accepted by this resource. + schema: + type: string + const: GET + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" From af5339f8f7daf7b7c9a7662b22bf384f34431170 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:24:42 -0700 Subject: [PATCH 14/22] test(instrument-http): cover encoded multilingual family refs --- tests/instrument_http_percent_encoding.rs | 127 ++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/instrument_http_percent_encoding.rs diff --git a/tests/instrument_http_percent_encoding.rs b/tests/instrument_http_percent_encoding.rs new file mode 100644 index 00000000..d1649f7c --- /dev/null +++ b/tests/instrument_http_percent_encoding.rs @@ -0,0 +1,127 @@ +//! Regression coverage for percent-encoded multilingual instrument references. +//! +//! URI path segments are transmitted as percent-encoded UTF-8 by ordinary HTTP +//! clients. The public catalog must decode that transport spelling exactly once, +//! validate the decoded opaque product identity, and reject encoded separators or +//! malformed encodings instead of making multilingual families unreachable. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +fn published_multilingual() -> InstrumentRelease { + let release_ref = "release_multilingual_family_v1"; + let instrument_ref = "instrument_성격_東京"; + let mut release = InstrumentRelease::new( + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_multilingual_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap(), + 10_000, + ) + .unwrap(); + release + .apply_command( + "publication_review_multilingual", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release + .bind_publication_evidence( + PublicationEvidenceRecord::new( + "publication_evidence_multilingual_v1", + "evidence_policy_multilingual_v1", + release_ref, + "instrument_version_multilingual_v1", + &["item_version_001", "item_version_002"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_multilingual_v1"], + &["recovery_multilingual_v1"], + &["approval_multilingual_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(), + ) + .unwrap(); + release + .apply_command( + "publication_publish_multilingual", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn get(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn percent_encoded_utf8_family_ref_resolves_to_exact_stored_identity() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual()]); + let response = handle_instrument_http_request( + &get("/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC"), + &runtime, + ); + + assert_eq!(response.status(), 200); + assert!(response + .body() + .contains("\"instrument_ref\":\"instrument_성격_東京\"")); +} + +#[test] +fn encoded_separator_and_malformed_percent_escape_fail_closed() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual()]); + + for path in [ + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC%2Fextra", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%", + "/v1/instruments/%20instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC", + ] { + let response = handle_instrument_http_request(&get(path), &runtime); + assert_eq!(response.status(), 400, "{path}"); + assert_eq!(response.content_type(), "application/problem+json"); + } +} From ffba05bf3a972892e36665be8b97aeed29b0d723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:25:38 -0700 Subject: [PATCH 15/22] test(instrument-http): exercise strict percent-decoding boundaries --- tests/instrument_http_percent_encoding.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/instrument_http_percent_encoding.rs b/tests/instrument_http_percent_encoding.rs index d1649f7c..b5cf1d15 100644 --- a/tests/instrument_http_percent_encoding.rs +++ b/tests/instrument_http_percent_encoding.rs @@ -100,15 +100,17 @@ fn get(path: &str) -> String { #[test] fn percent_encoded_utf8_family_ref_resolves_to_exact_stored_identity() { let runtime = InstrumentHttpRuntime::new(vec![published_multilingual()]); - let response = handle_instrument_http_request( - &get("/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC"), - &runtime, - ); - assert_eq!(response.status(), 200); - assert!(response - .body() - .contains("\"instrument_ref\":\"instrument_성격_東京\"")); + for path in [ + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC", + "/v1/instruments/instrument_%ec%84%b1%ea%b2%a9_%e6%9d%b1%e4%ba%ac", + ] { + let response = handle_instrument_http_request(&get(path), &runtime); + assert_eq!(response.status(), 200, "{path}"); + assert!(response + .body() + .contains("\"instrument_ref\":\"instrument_성격_東京\"")); + } } #[test] @@ -118,6 +120,9 @@ fn encoded_separator_and_malformed_percent_escape_fail_closed() { for path in [ "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC%2Fextra", "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%A", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%ZZ", + "/v1/instruments/instrument_%FF", "/v1/instruments/%20instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC", ] { let response = handle_instrument_http_request(&get(path), &runtime); From bcf45c7472887a764a8b0e77ebae583878b44f5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:53:18 -0700 Subject: [PATCH 16/22] test(instrument-http): accept encoded multilingual family refs --- ...strument_http_percent_encoded_reference.rs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/instrument_http_percent_encoded_reference.rs diff --git a/tests/instrument_http_percent_encoded_reference.rs b/tests/instrument_http_percent_encoded_reference.rs new file mode 100644 index 00000000..787e48a9 --- /dev/null +++ b/tests/instrument_http_percent_encoded_reference.rs @@ -0,0 +1,126 @@ +//! HTTP path regression for visible multilingual opaque instrument references. +//! +//! Product references preserve visible multilingual material. The HTTP path +//! representation may percent-encode those UTF-8 bytes; transport decoding must +//! recover the exact opaque reference without accepting encoded path separators, +//! malformed escapes, whitespace aliases, or numeric-like identities. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const INSTRUMENT_REF: &str = "instrument_ref_가나다_東京_éclair"; +const ENCODED_INSTRUMENT_REF: &str = + "instrument_ref_%EA%B0%80%EB%82%98%EB%8B%A4_%E6%9D%B1%E4%BA%AC_%C3%A9clair"; + +fn published_multilingual_release() -> InstrumentRelease { + let manifest = InstrumentReleaseManifest::new( + "release_multilingual_catalog_v1", + INSTRUMENT_REF, + "instrument_version_multilingual_v1", + "construct_multilingual_v1", + &["item_version_multilingual_001"], + "ko-KR", + "assessment_spec_multilingual_v1", + "scoring_version_multilingual_v1", + "calibration_multilingual_v1", + Some("norm_version_multilingual_v1"), + "narrative_version_multilingual_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap(); + let evidence = PublicationEvidenceRecord::new( + "publication_evidence_multilingual_v1", + "evidence_policy_self_reflection_v1", + "release_multilingual_catalog_v1", + "instrument_version_multilingual_v1", + &["item_version_multilingual_001"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_multilingual_v1", + "scoring_version_multilingual_v1", + "calibration_multilingual_v1", + Some("norm_version_multilingual_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_multilingual_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_multilingual_v1"], + &["recovery_multilingual_v1"], + &["approval_psychometrics_multilingual_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(); + let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); + release + .apply_command( + "publication_review_multilingual_v1", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_multilingual_v1", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn percent_encoded_utf8_family_path_resolves_the_exact_multilingual_reference() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); + let response = handle_instrument_http_request( + &request(&format!("/v1/instruments/{ENCODED_INSTRUMENT_REF}")), + &runtime, + ); + + assert_eq!(response.status(), 200); + assert!(response + .body() + .contains(&format!("\"instrument_ref\":\"{INSTRUMENT_REF}\""))); + assert!(response.body().contains("release_multilingual_catalog_v1")); +} + +#[test] +fn malformed_or_separator_percent_encoding_fails_closed() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); + + for path in [ + "/v1/instruments/instrument_ref_%GG", + "/v1/instruments/instrument_ref_%E3%81", + "/v1/instruments/instrument_ref_%2Fhidden", + "/v1/instruments/%20instrument_ref_%EA%B0%80", + ] { + let response = handle_instrument_http_request(&request(path), &runtime); + assert_eq!(response.status(), 400, "{path}"); + assert!(response + .body() + .contains("urn:psychometrics-commons:problem:bad-request")); + } +} From 94a9005558bfeeb864c17f0de4b047fab6d45117 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:54:58 -0700 Subject: [PATCH 17/22] fix(instrument-http): decode canonical UTF-8 path segments --- src/instrument_http.rs | 60 +++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index 3c8d57f3..f7e0938b 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -104,8 +104,10 @@ impl InstrumentHttpResponse { /// Translate one raw HTTP/1.1 request into a catalog list or family response. /// -/// Unknown methods, encoded or numeric instrument references, and families with -/// no startable published release fail closed with RFC 9457 problem details. +/// Unknown methods, malformed/unsafe encoded references, numeric references, +/// and families with no startable published release fail closed with RFC 9457 +/// problem details. Valid UTF-8 percent encoding is decoded exactly once so +/// visible multilingual opaque references remain addressable over HTTP. #[must_use] pub fn handle_instrument_http_request( request: &str, @@ -182,7 +184,7 @@ fn handle_list(runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { } fn handle_family(path: &str, runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { - let Some(instrument_ref) = path + let Some(encoded_instrument_ref) = path .strip_prefix("/v1/instruments/") .filter(|value| !value.is_empty() && !value.contains('/')) else { @@ -193,16 +195,14 @@ fn handle_family(path: &str, runtime: &InstrumentHttpRuntime) -> InstrumentHttpR "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", ); }; - if instrument_ref.contains('%') + let Some(instrument_ref) = decode_path_segment(encoded_instrument_ref) else { + return invalid_instrument_reference(); + }; + if instrument_ref.contains('/') || instrument_ref.chars().any(char::is_whitespace) - || normalized_reference(instrument_ref).is_none() + || normalized_reference(&instrument_ref) != Some(instrument_ref.as_str()) { - return InstrumentHttpResponse::problem( - 400, - "urn:psychometrics-commons:problem:bad-request", - "Bad Request", - "instrument_ref must be an opaque non-numeric family identity", - ); + return invalid_instrument_reference(); } let releases: Vec<&InstrumentRelease> = published_releases(runtime) .into_iter() @@ -220,12 +220,48 @@ fn handle_family(path: &str, runtime: &InstrumentHttpRuntime) -> InstrumentHttpR 200, format!( "{{\"instrument_ref\":{},\"releases\":{}}}", - json_string(instrument_ref), + json_string(&instrument_ref), release_array(&releases) ), ) } +fn invalid_instrument_reference() -> InstrumentHttpResponse { + InstrumentHttpResponse::problem( + 400, + "urn:psychometrics-commons:problem:bad-request", + "Bad Request", + "instrument_ref must be an opaque non-numeric family identity", + ) +} + +fn decode_path_segment(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = hex_value(*bytes.get(index + 1)?)?; + let low = hex_value(*bytes.get(index + 2)?)?; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + fn published_releases(runtime: &InstrumentHttpRuntime) -> Vec<&InstrumentRelease> { let mut releases: Vec<&InstrumentRelease> = runtime .releases From 7eeef16104f214b8cddc9bfbd8d9ba84c5aed1a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:55:48 -0700 Subject: [PATCH 18/22] test(instrument-http): cover percent decoder boundaries --- ...strument_http_percent_encoded_reference.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/instrument_http_percent_encoded_reference.rs b/tests/instrument_http_percent_encoded_reference.rs index 787e48a9..5f272e94 100644 --- a/tests/instrument_http_percent_encoded_reference.rs +++ b/tests/instrument_http_percent_encoded_reference.rs @@ -20,6 +20,8 @@ const EVIDENCE_DIGEST: &str = const INSTRUMENT_REF: &str = "instrument_ref_가나다_東京_éclair"; const ENCODED_INSTRUMENT_REF: &str = "instrument_ref_%EA%B0%80%EB%82%98%EB%8B%A4_%E6%9D%B1%E4%BA%AC_%C3%A9clair"; +const MIXED_CASE_ENCODED_INSTRUMENT_REF: &str = + "instrument_ref_%EA%B0%80%EB%82%98%EB%8B%A4_%E6%9D%B1%E4%BA%AC_%c3%a9clair"; fn published_multilingual_release() -> InstrumentRelease { let manifest = InstrumentReleaseManifest::new( @@ -95,16 +97,19 @@ fn request(path: &str) -> String { #[test] fn percent_encoded_utf8_family_path_resolves_the_exact_multilingual_reference() { let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); - let response = handle_instrument_http_request( - &request(&format!("/v1/instruments/{ENCODED_INSTRUMENT_REF}")), - &runtime, - ); - assert_eq!(response.status(), 200); - assert!(response - .body() - .contains(&format!("\"instrument_ref\":\"{INSTRUMENT_REF}\""))); - assert!(response.body().contains("release_multilingual_catalog_v1")); + for encoded_reference in [ENCODED_INSTRUMENT_REF, MIXED_CASE_ENCODED_INSTRUMENT_REF] { + let response = handle_instrument_http_request( + &request(&format!("/v1/instruments/{encoded_reference}")), + &runtime, + ); + + assert_eq!(response.status(), 200, "{encoded_reference}"); + assert!(response + .body() + .contains(&format!("\"instrument_ref\":\"{INSTRUMENT_REF}\""))); + assert!(response.body().contains("release_multilingual_catalog_v1")); + } } #[test] @@ -112,6 +117,7 @@ fn malformed_or_separator_percent_encoding_fails_closed() { let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); for path in [ + "/v1/instruments/instrument_ref_%", "/v1/instruments/instrument_ref_%GG", "/v1/instruments/instrument_ref_%E3%81", "/v1/instruments/instrument_ref_%2Fhidden", From 537484c0502d367a3e385f21d3ada3576623ec8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:56:19 -0700 Subject: [PATCH 19/22] test(instrument-http): preserve timeout error boundary --- src/instrument_http.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index f7e0938b..f8d27d36 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -470,24 +470,20 @@ mod unit_tests { apply_request_read(&mut oversized, b"y", Ok(1)).unwrap(), RequestReadProgress::Complete )); - assert!(matches!( - apply_request_read( - &mut Vec::new(), - b"", - Err(io::Error::new(ErrorKind::TimedOut, "timeout")) - ) - .unwrap(), - RequestReadProgress::Complete - )); - assert!(matches!( - apply_request_read( - &mut Vec::new(), - b"", - Err(io::Error::new(ErrorKind::WouldBlock, "block")) - ) - .unwrap(), - RequestReadProgress::Complete - )); + let timeout = apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::TimedOut, "timeout")), + ) + .expect_err("read timeout must remain an I/O timeout"); + assert_eq!(timeout.kind(), ErrorKind::TimedOut); + let would_block = apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::WouldBlock, "block")), + ) + .expect_err("would-block must remain an I/O error"); + assert_eq!(would_block.kind(), ErrorKind::WouldBlock); assert!(apply_request_read(&mut Vec::new(), b"", Err(io::Error::other("boom"))).is_err()); assert!(parse_request_line("GET /v1/instruments SMTP/1.0\r\n\r\n").is_none()); assert!(parse_request_line("GET /v1/instruments HTTP/1.1 leftover\r\n\r\n").is_none()); From df3da6e62d120dda59d2bb5fa31010d1986fcbb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:58:40 -0700 Subject: [PATCH 20/22] fix(instrument-http): propagate slow-client read timeout --- src/instrument_http.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index f8d27d36..520dd95b 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -404,12 +404,6 @@ fn apply_request_read( Ok(RequestReadProgress::Continue) } } - Err(error) - if error.kind() == io::ErrorKind::TimedOut - || error.kind() == io::ErrorKind::WouldBlock => - { - Ok(RequestReadProgress::Complete) - } Err(error) => Err(error), } } From 254d4bcec07a78450f35eee6465cd376092e90d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:22:07 +0000 Subject: [PATCH 21/22] fix(pr-165): address review feedback --- tests/instrument_http_contract.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/instrument_http_contract.rs b/tests/instrument_http_contract.rs index 3a7f0fd7..f8665e0c 100644 --- a/tests/instrument_http_contract.rs +++ b/tests/instrument_http_contract.rs @@ -285,6 +285,19 @@ fn invalid_paths_and_methods_fail_closed_without_leaking_catalog_rows() { let malformed = handle_instrument_http_request("not-an-http-request", &runtime); assert_eq!(malformed.status(), 400); + + let truncated = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1\r\n\r\n", + &runtime, + ); + assert_eq!(truncated.status(), 200); + + let missing_version = handle_instrument_http_request( + "GET /v1/instruments\r\n\r\n", + &runtime, + ); + assert_eq!(missing_version.status(), 400); + assert_eq!(runtime.catalog_count(), 1); } From bb9028714d996871ed2588ea9a3aa83fd581af84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 23:01:04 +0900 Subject: [PATCH 22/22] test(api): close instrument catalog coverage gaps Cover empty family paths, oversized unterminated socket requests, and request-line parser short-circuit outcomes. Keep production validation unchanged and apply pinned rustfmt formatting. Co-Authored-By: Claude Code --- src/instrument_http.rs | 2 ++ tests/instrument_http_contract.rs | 52 +++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/instrument_http.rs b/src/instrument_http.rs index 520dd95b..01fcb2eb 100644 --- a/src/instrument_http.rs +++ b/src/instrument_http.rs @@ -480,6 +480,8 @@ mod unit_tests { assert_eq!(would_block.kind(), ErrorKind::WouldBlock); assert!(apply_request_read(&mut Vec::new(), b"", Err(io::Error::other("boom"))).is_err()); assert!(parse_request_line("GET /v1/instruments SMTP/1.0\r\n\r\n").is_none()); + assert!(parse_request_line("GET /v1/instruments SMTP/1.0 extra\r\n\r\n").is_none()); + assert!(parse_request_line("GET /v1/instruments HTTP/1.1\r\n\r\n").is_some()); assert!(parse_request_line("GET /v1/instruments HTTP/1.1 leftover\r\n\r\n").is_none()); } } diff --git a/tests/instrument_http_contract.rs b/tests/instrument_http_contract.rs index f8665e0c..dba37667 100644 --- a/tests/instrument_http_contract.rs +++ b/tests/instrument_http_contract.rs @@ -11,7 +11,7 @@ use psychometrics_commons_runtime::instrument::{ }; use psychometrics_commons_runtime::instrument_http::{ accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, - InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, + InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, INSTRUMENT_HTTP_MAX_REQUEST_BYTES, }; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; @@ -277,6 +277,14 @@ fn invalid_paths_and_methods_fail_closed_without_leaking_catalog_rows() { ); assert_eq!(nested.status(), 404); + let missing_family = handle_instrument_http_request(&get_request("/v1/instruments/"), &runtime); + assert_eq!(missing_family.status(), 404); + assert_eq!(missing_family.content_type(), "application/problem+json"); + assert!(missing_family + .body() + .contains("urn:psychometrics-commons:problem:not-found")); + assert!(!missing_family.body().contains("release_big_five_ko_v1")); + let put_family = handle_instrument_http_request( "PUT /v1/instruments/instrument_big_five HTTP/1.1\r\nHost: localhost\r\n\r\n", &runtime, @@ -286,16 +294,11 @@ fn invalid_paths_and_methods_fail_closed_without_leaking_catalog_rows() { let malformed = handle_instrument_http_request("not-an-http-request", &runtime); assert_eq!(malformed.status(), 400); - let truncated = handle_instrument_http_request( - "GET /v1/instruments HTTP/1.1\r\n\r\n", - &runtime, - ); + let truncated = + handle_instrument_http_request("GET /v1/instruments HTTP/1.1\r\n\r\n", &runtime); assert_eq!(truncated.status(), 200); - let missing_version = handle_instrument_http_request( - "GET /v1/instruments\r\n\r\n", - &runtime, - ); + let missing_version = handle_instrument_http_request("GET /v1/instruments\r\n\r\n", &runtime); assert_eq!(missing_version.status(), 400); assert_eq!(runtime.catalog_count(), 1); @@ -346,6 +349,37 @@ fn listener_serves_one_published_catalog_request() { assert!(body.contains("application/json")); } +#[test] +fn listener_rejects_oversized_unterminated_request_without_catalog_rows() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = b"GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\nX-Padding: ".to_vec(); + request.resize(INSTRUMENT_HTTP_MAX_REQUEST_BYTES + 1, b'x'); + client.write_all(&request).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + server.join().unwrap().unwrap(); + + assert!( + response.starts_with("HTTP/1.1 400 Bad Request"), + "{response}" + ); + assert!(response.contains("application/problem+json")); + assert!(response.contains("urn:psychometrics-commons:problem:bad-request")); + assert!(!response.contains("release_big_five_ko_v1")); +} + #[test] fn listener_fails_closed_for_truncated_and_extra_token_requests() { let runtime = InstrumentHttpRuntime::new(vec![published_korean()]);