Back to Portfolio
ArticleTechnologyMay 20, 2026

Schema.Redacted Across the HttpApi Wire

Event
The Innovation Lab
Overview

Schema.Redacted and Schema.RedactedFromValue are encode-forbidden by design — so they cannot be wire fields on either side of HttpApi. The reference repo demonstrates the failure modes with four endpoints, pins the exact encoder error messages in a backend test, and shows the working pattern: plain validated wire values, Redacted.make on receipt, Redacted.value at exactly one audit boundary inside the SQL repo.

Technologies
Effect-TSSchema.RedactedHttpApiPIIOpenTelemetrySecurity
Schema.Redacted Across the HttpApi Wire preview
Introduction

Schema.Redacted Across the HttpApi Wire

PII has two threat models, and they want different tools. Bytes on the wire are TLS’s problem — the transport encrypts everything indiscriminately. Bytes in process are a different problem: a stack trace, a log line, an accidental JSON.stringify can leak a value that TLS already delivered safely. Schema.Redacted answers the second threat model by wrapping the value so its toString renders <redacted>. Try to put it on the HttpApi wire — request payload or response success body — and the encoder breaks, because the JSON codec annotation is Getter.forbidden by design.

This article answers a literal question from the Effect community: “How should I handle Redacted attributes on a response schema with HttpApi? Schema.RedactedFromValue does not support encoding to a string.” The reference repo walks four endpoints — one working, two anti-patterns, one alternative — and the backend test (redacted-encode-failure.test.ts) that pins the exact failure messages the encoder emits.

Reference Repository

github.com/dlb-technologies-llc/effect-redacted

Four endpoints — one working, two anti-patterns, one alternative — plus redacted-encode-failure.test.ts which asserts on the exact encoder failure messages. Clone it if you want to follow along.

In-process, not on the wire

Redacted lives entirely server-side — a wrapper that hides values from logs, traces, and stringification. The wire stays plain validated branded strings: Email, Phone, NetWorth.

Encode-forbidden by design

Both Schema.Redacted and Schema.RedactedFromValue carry Getter.forbidden on their JSON codecs. A typed HttpApiClient cannot send them; the response encoder cannot serialize them.

Audit boundary

Redacted.value(...) is grepped in CI. The sole call site lives at the SQL parameter binding inside ApplicantRepo — every unwrap is visible, reviewable, and accounted for.

Reference repo runs Effect 4.0.0-beta.67 with effect/unstable/httpapi and @effect/platform-bun.

The Four Endpoints

One Pattern, Two Anti-Patterns, One Alternative

POST /intake✓ Works
Wire shape

Plain branded strings (Email, Phone, NetWorth, …).

Outcome

Server wraps fields in Redacted inside IntakeService; spans/logs render as <redacted:label>.

Lesson

The pattern. Wire = plain validated strings; Redacted wrapping is a server-side responsibility.

POST /intake-echo-redacted✗ Client cannot send
Wire shape

Schema.RedactedFromValue(NetWorth) on payload.

Outcome

A typed HttpApiClient cannot encode the payload — Cannot encode Redacted. A raw curl reaches the handler (decode is permitted) but the handler returns a documented IntakeProcessingError.

Lesson

Request-side anti-pattern. Don’t put Redacted on the wire; the typed client breaks.

POST /intake-redacted-response✗ Server fails
Wire shape

Plain payload, but Schema.Redacted(NetWorth) on the success body.

Outcome

Server-side encoder fails — Cannot serialize Redacted with label: "netWorth". HttpApi catches the encoder failure and returns an error response with the cause on the span.

Lesson

Response-side anti-pattern. This is the literal answer to the question: Redacted cannot be a response body field.

POST /intake-masked-response✓ Works
Wire shape

Plain payload, MaskedEmail (a branded plain string like a**@example.com) in the response.

Outcome

Server hand-masks via maskEmail(...) and returns a plain string.

Lesson

Working alternative. If you must echo something sensitive-looking in a response, mask server-side and put the masked string on the wire — not a Redacted.

All four endpoints share the same IntakePayload decode path and the same IntakeService.intake(...) server-side wrapping. The differences are purely on the wire — what shape the request payload takes, and what shape the response success body takes. The next section walks the working pattern in detail; the section after that diagnoses why the two anti-patterns fail.

The Working Pattern

Plain Wire, Server-Side Wrap

The wire payload is a plain Schema.Struct over Applicant.fields Email, Phone, and NetWorth are all branded strings/ints with regex/range checks, no Schema.Redacted anywhere on the request or response. IntakeService.persist wraps netWorth in Redacted exactly once on receipt and threads the wrapped value through the rest of the request lifecycle.

packages/shared/src/http/payloads.ts

The wire schema. IntakePayload is derived from Applicant.fields — change the Model and the wire follows automatically.

The Anti-Patterns

Where the Encoder Refuses

Both anti-patterns share one root cause — Getter.forbidden on the JSON codec annotation that Schema.Redacted and Schema.RedactedFromValue carry by design. Same encoder. Two surfaces. Two failure modes.

Request-side: Schema.RedactedFromValue

The payload schema wraps netWorth in Schema.RedactedFromValue(NetWorth). A typed HttpApiClient cannot encode it.

Cannot encode Redacted with label: "netWorth"

Schema.RedactedFromValue’s JSON codec annotation is Getter.forbidden. The typed HttpApiClient calls through Schema.toCodecJson(...), which never reaches the wire. The handler IS reachable via raw curl (decode is permitted on the server) but the documented IntakeProcessingError is returned, with a defect thrown from Effect.gen to mark the reachability boundary unmistakably.

Response-side: Schema.Redacted

The success schema wraps netWorth in Schema.Redacted(NetWorth). The server’s response encoder fails when writing the body.

Cannot serialize Redacted with label: "netWorth"

HttpApi serializes response bodies through Schema.toCodecJson(...). Schema.Redacted shares the same Getter.forbidden codec annotation as Schema.RedactedFromValue. The handler completes happily (the business logic returns a Redacted.make(...)-wrapped value); the failure surfaces when the response encoder tries to write the body. HttpApi catches the encoder failure and returns an error response; the cause is attached to the active span.

Both failures share one root cause: Getter.forbidden on the JSON codec. The encoder behavior is intentional. The next section shows the backend test that pins these exact failure messages, so a future Effect release that silently changes the encoder behavior surfaces immediately.

Pinning the Failure

One Test File, Four Symmetric Assertions

The failure modes the previous section diagnosed are pinned by a single test file in the reference repo: packages/backend/test/http/redacted-encode-failure.test.ts. It routes through Schema.toCodecJson(...) because the Getter.forbidden encoder lives on the JSON codec annotation, not on the default Type→Encoded codec. HttpApi serializes responses through the JSON codec — so this is the exact failure path the HTTP server exhibits.

Four it.effect assertions: two negative (the anti-patterns must fail with specific messages), two positive (the working pattern and the working alternative must round-trip cleanly). Symmetric coverage in both directions.

packages/backend/test/http/redacted-encode-failure.test.ts
  • toCodecJson(...) is load-bearing. The Getter.forbidden encoder lives on the JSON codec annotation, not the default Type→Encoded codec. HttpApi serializes through the JSON codec, so this is the exact failure path the HTTP server exhibits — not a different one.
  • Two negative, two positive. The first pair pins the failure messages on both anti-patterns. The second pair pins that the working alternative (MaskedResponse) and the working pattern (IntakePayload) round-trip cleanly. A regression in either direction surfaces here.
  • Production failure modes caught: (1) a future Effect release silently changes the encoder behavior from “forbidden” to “passthrough” — this test fails the moment that happens, before any secret can leak; (2) README drift — if upstream messages change, the test fails and the README needs updating.

Lives in packages/backend/test/http/redacted-encode-failure.test.ts. Runs in the unit vitest project — no docker required.

Chain of Custody

Wire to Audit Boundary, One Unwrap

Redacted is an in-process wrapper — it lives entirely on the server side of the wire. The same rule applies regardless of transport (HTTP, RPC, Cluster messages): plain validated values cross the wire; Redacted.make(...) wraps them on receipt; Redacted.value(...) unwraps at exactly one place per sensitive field — the audit boundary.

Step 1
Wire (request)

Plain validated branded string crosses the transport. The schema is NetWorth = Schema.Int.pipe(check(>= 0), check(<= 2_000_000_000)) — no Redacted on the wire. Decoded by HttpApi’s request codec before it reaches any service.

Step 2
Service receipt

IntakeService.persist calls Redacted.make(payload.netWorth, { label: "netWorth" }) exactly once. From here on the value type is Redacted<NetWorth>, and the unwrapped int never appears in another binding.

Step 3
In-process (logs, spans, errors)

Redacted.toString() returns <redacted:netWorth>. OTel attribute serialization calls String() on non-primitive values, so spans and logs render the label automatically. Greppable proof: bun --filter @effect-redacted/backend test:unit plus eyeballing the ConsoleSpanExporter dump.

Step 4
Audit boundary (DB binding)

The sole Redacted.value(...) call lives inside ApplicantRepo.layer.insert. Grep Redacted.value against packages/backend/src/ — exactly one match. Line numbers drift; the grep count does not.

Below: ApplicantRepo.layer from packages/backend/src/db/ApplicantRepo.ts. The // ← THE audit boundary comment on the Redacted.value(...) line is an editorial annotation added here for emphasis — it does not exist in the source.

packages/backend/src/db/ApplicantRepo.ts
The three layers compose; none subsumes another.
TLS

Bytes between client and server. Different threat, different tool.

Redacted + Redacted.value

Bytes in process. This article.

Encryption at rest

pgcrypto, KMS — bytes once on disk. Different threat, different tool.

Telemetry

Why Spans and Logs Don’t Leak

The contract: if you wrapped, you’re safe; if you forgot to wrap, the value lands in the exporter as plaintext and a grep of the dump surfaces the leak in dev. The mechanism is mundane — OTel attribute serialization calls String() on non-primitive values, and Redacted.toString() is defined to return <redacted:label>. No allow-list filter at the exporter, no explicit redaction transform.

Source annotation

What IntakeService annotates — the same payload fields plus the wrapped netWorth.

Exporter output

What the ConsoleSpanExporter prints — the redacted value never materializes as plaintext.

Telemetry layer (packages/backend/src/infra/TelemetryLive.ts)

Honest caveat. This property holds because both Effect.annotateLogs (with pretty and json formatters) and OTel span attributes serialize values through String()-based paths. A custom structured logger that uses JSON.stringify on an object containing the unwrapped value will not have this property — JSON.stringify(redacted) returns '"<redacted:label>"', but if you previously called Redacted.value(...) and stored the raw value somewhere, that raw value will serialize plainly. Discipline at the audit boundary is required.

Summary

What the Pattern Actually Buys You

Schema.Redacted and Schema.RedactedFromValue are encode-forbidden by design. Their JSON codec annotation is Getter.forbidden. That means the typed HttpApiClient cannot serialize a payload that contains either one, and the server’s response encoder cannot serialize a success body that contains either one. The literal answer to “how do I return a Redacted field from a response?” is: you don’t.

What you do instead is the pattern: plain validated branded strings on the wire, Redacted.make(...) to wrap on receipt, Redacted.value(...) at exactly one audit boundary per sensitive field. Spans and logs render <redacted:label> automatically because OTel calls String() on non-primitive values. The reference repo at dlb-technologies-llc/effect-redacted demonstrates the four endpoints, the test that pins the encoder behavior, and the single Redacted.value call site in ApplicantRepo.

Takeaways

Four Things to Carry Away

In-process, not on the wire

Schema.Redacted lives entirely server-side. Wire = plain validated branded strings. Encoder is forbidden on both anti-pattern surfaces by design — there's no path that makes a Redacted value into wire bytes through a typed Effect HttpApi.

Two failures, one root cause

Both Schema.Redacted and Schema.RedactedFromValue carry Getter.forbidden on their JSON codec annotation. Typed HttpApiClient cannot send; response encoder cannot serialize. The behavior is intentional, and one backend test pins both failure messages.

One audit boundary

Redacted.value(...) appears exactly once in packages/backend/src/db/ApplicantRepo.ts — at the SQL parameter binding for net_worth. Grep against packages/backend/src and the count IS the audit. Line numbers drift; the grep count does not.

OTel renders <redacted:label> automatically

Span attribute serialization calls String() on non-primitive values, and Redacted.toString() is the label-emitter. No allowlist filter required at the exporter layer. The discipline lives at the audit boundary, not at the sink.

Clone the reference repo

github.com/dlb-technologies-llc/effect-redacted

Four endpoints, one test file pinning the encoder behavior, one integration test (testcontainers + real Postgres) pinning the DB-side round-trip, and an Astro/React intake-form frontend that consumes the working endpoint via @effect/atom-react + AtomHttpApi.Service. Clone it and run bun --filter @effect-redacted/backend test:unit.

Ready to transform your engineering?

Whether you need technical leadership, enterprise development, or team optimization—let's discuss how we can help.