Files
tech-log-backend/src/app-bootstrap/src/main/resources/application.yml
T
DongHyeonkaandClaude Opus 5 561d02ae3a feat: 프로젝트 게시와 홈 focus, 그리고 기록 사이 연결을 실제로 가능하게 한다
계약에 선언만 되어 있고 구현이 없던 네 오퍼레이션과, 의도된 스텁으로 남아 있던
catalog 두 종류가 공개 화면 다섯 곳을 조용히 비워 두고 있었다.

프로젝트 게시 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데,
공개 조회들(프로젝트 목록·상세·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는
경로가 없었으므로 프로젝트는 만들 수는 있어도 공개될 수는 없었다. 릴리스가 자체 경로를
갖는 것과 같은 이유로, 문서 파이프라인에 끼워 넣지 않고 원본 상태·투영·canonical
route 세 곳을 한 트랜잭션에서 함께 세운다.

홈 focus — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을
읽고 쓸 use case 가 없었다. 지목한 대상이 실제로 있는지는 여기서 확인한다. 테이블에
FK 가 없어(설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 도 저장되고,
그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 아무것도 안 나오는,
이유를 알 수 없는 실패가 된다. 대상이 공개인지는 확인하지 않는다: 미리 지목해 두고
게시와 동시에 뜨게 하는 것이 정상적인 순서다.

catalog RELATION/EVIDENCE — 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 로
남아 있었다. 그래서 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성
중에 고르는 것이라 작업본까지 포함하고(두 문서를 같이 쓰면서 서로 잇는 것이 정상적인
순서다), EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다.

계약은 이 네 오퍼레이션을 ProblemDetails 모양으로 두고 있었다. 백엔드가 모든 JSON
응답을 envelope 으로 감싸므로 구현하는 순간 어긋난다 — 나머지와 같은 모양으로 옮겼다.
`/home-focus` 는 케밥 세그먼트라 D19(AIP-122)를 위반해 `/home/focus` 로 나눴다.

함께 고친 것들 (모두 이 작업 전부터 빨간 상태였다):
- `error-codes.yaml` 의 DOCUMENT_NOT_FOUND 가 표면마다 하나씩 두 행이었다. 이
  레지스트리의 식별자는 code 하나뿐이라 로딩 자체가 깨졌고, 그 여파로 거버넌스 테스트
  네 개와 outbox 계약 테스트가 함께 넘어졌다. 같은 코드는 같은 말을 해야 한다.
- 벤더된 계약 세 개의 MANIFEST.sha256 이 실제 파일과 어긋나 있었다.
- ActuatorSecurityHttpTest 는 "DB·Redis 없는 슬라이스"라고 적어 두고 Redis 자동설정을
  막지 않아, localhost:6379 연결 실패가 /actuator/health 를 503 으로 만들었다. 보안
  태세와 무관한 이유로 빨개지던 테스트다.
- 테스트 JVM 힙이 Gradle 기본 512m 이라 Spring context 캐시 + ArchUnit +
  Testcontainers 조합에서 OOM 이 났다. 증상이 테스트 실패가 아니라 "Executor 를 완료할
  수 없음"이어서 원인을 가리켰다.
- APP_SESSION_TIMEOUT 이 env-keys 레지스트리에 없었다.

새 SQL 은 실제 PostgreSQL 위에서 돌린다 — 컴파일도 단위 테스트도 컬럼 이름을 검증하지
못한다는 것이 이 파일이 존재하는 이유다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:19:07 +09:00

763 lines
43 KiB
YAML

# =============================================================================
# Mirrors src/.env into Spring's Environment. Comments document allowed values;
# input validation lives in the *Settings records under each module.
# =============================================================================
spring:
application:
# free-form string (also exposed as the JSON log "app" field)
name: ${APP_NAME}
mvc:
problemdetails:
# Architectural decision D1/D5: RFC 7807 ProblemDetail is rejected in favour
# of the custom {success,data,error,meta} envelope. Pin OFF explicitly so a
# future Spring Boot default flip cannot silently re-enable it.
enabled: false
web:
error:
# always | never | on_param
include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE}
# always | never | on_param
include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE}
profiles:
# common values: local | dev | stage | prod (free-form)
active: ${SPRING_PROFILES_ACTIVE:local}
datasource:
# jdbc URL: jdbc:postgresql://host:5432/db
url: ${APP_DATASOURCE_URL}
username: ${APP_DATASOURCE_USERNAME}
password: ${APP_DATASOURCE_PASSWORD}
driver-class-name: ${APP_DATASOURCE_DRIVER}
hikari:
# D1 (feature-database-connection-pool-contract): small-pool axiom + PostgreSQL formula
# starting point (maximumPoolSize = cores * 2 + effective_spindle_count, adjust via load
# test). Fixed-size pool recommended (minimumIdle = maximumPoolSize). Must satisfy
# application-port D12 REQUIRES_NEW lower bound:
# maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
# Value owner: feature-env-driven-runtime-configuration (APP_DATASOURCE_POOL_MAX_SIZE).
# integer >= 1
maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE}
# D1: fixed-size pool recommended (minimumIdle = maximumPoolSize per HikariCP #HIKARI-CFG-C8).
# Current registry value min-idle=2 is a MIN_IDLE_POLICY_DRIFT vs the fixed-size
# recommendation; value alignment is delegated to feature-env-driven-runtime-configuration.
# integer >= 0
minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE}
# D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than
# holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
# env-keys.yaml default "5s", plain milliseconds and ISO-8601 values are parsed by
# HikariPoolConstraintValidator with Spring Boot DurationStyle; invalid values fail startup.
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
# milliseconds
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
# D3 (HIKARI-CFG-C2): must be several seconds shorter than the DB/infrastructure idle
# timeout (DB wait_timeout, PgBouncer idle_transaction_timeout, firewall NAT timeout).
# Current default (30 min) is a placeholder until the actual DB wait_timeout is confirmed
# (see §Claims "DB wait_timeout 미확인" — needs-confirmation). Recommended: DB_idle_limit
# minus at least 60 s as a conservative margin. Value owner: feature-env-driven.
# milliseconds
max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME}
# D4 (HIKARI-CFG-C4): greenfield — ping idle connections to prevent NAT/firewall/DB
# idle-kill from silently dropping them. Constraint: keepalive-time < max-lifetime
# (enforced by HikariPoolConstraintValidator). Provisional literal 120 000 ms (2 min);
# adjust once actual DB/firewall idle timeout is confirmed (§Claims).
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_KEEPALIVE_TIME registration delegated to
# feature-env-driven-runtime-configuration.
keepalive-time: 120000
# D5 (HIKARI-CFG-C5): greenfield — enable connection leak early warning. Value must be
# >= 2000 ms to activate (enforced by HikariPoolConstraintValidator; 0 = disabled).
# Provisional literal 30 000 ms (30 s) — chosen well above the estimated longest normal
# transaction (~5 s) to avoid false positives on legitimate slow operations.
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_LEAK_DETECTION_THRESHOLD delegated to
# feature-env-driven-runtime-configuration.
leak-detection-threshold: 30000
# D7 (HIKARI-CFG-C6): greenfield — must be < connection-timeout (HIKARI-CFG-C6),
# enforced by HikariPoolConstraintValidator. Resolves VALIDATION_TIMEOUT_CONFLICT:
# HikariCP default 5000 ms equals connection-timeout 5 s / 5000 ms → constraint
# violation. Provisional literal 3000 ms satisfies the constraint for connection-timeout
# values >= 3001 ms.
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_VALIDATION_TIMEOUT delegated to
# feature-env-driven-runtime-configuration.
validation-timeout: 3000
# D6 (HIKARI-CFG-C7): greenfield — keep positive (default = 1 ms) to fail fast when DB
# is unavailable at startup. Aligns with runtime-health startup validation and project-note
# §9 "잘못된 env 값 startup fail-fast" policy. Negative value disables fail-fast (allowed
# only in orchestration environments where DB may start after the app — coordinate with
# runtime-health-lifecycle branch).
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_INIT_FAIL_TIMEOUT delegated to
# feature-env-driven-runtime-configuration.
initialization-fail-timeout: 1
# ---------------------------------------------------------------------------
# D8 (feature-database-connection-pool-contract) — SLOW QUERY DETECTION
# POLICY ONLY — no library dependency added here. Implementation is DEFERRED
# pending local verification (TODO #3: confirm ParameterTransformer masking
# applies to slow-query listener output).
#
# Baseline (app layer, param-safe):
# datasource-proxy SlowQueryListener + ParameterTransformer ([REDACTED] masking).
# Requires the datasource-proxy / spring-boot-data-source-decorator dependency
# and a @Bean ParameterTransformer; deferred until masking behavior on slow-query
# output is locally verified.
#
# Production augment (DBA-owned):
# DB-side log_min_duration_statement — parameters included in extended-protocol
# output (PostgreSQL official security warning). DBA controls and redacts.
#
# Dev only (PROD-FORBIDDEN):
# Hibernate SQL_SLOW (LOG_QUERIES_SLOWER_THAN_MS) logs materialized SQL with
# substituted parameters — violates the "SQL/param 로그 금지" hard rule from
# feature-persistence-failure-baseline. NEVER enable in prod.
#
# Rejected:
# P6Spy — no built-in masking API; effective SQL exposes parameters by default
# with no safe override. Rejected per D8 (#C3/#C4).
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Flyway forward-only migration (feature-migration-startup-contract D1/D2/D4).
# Flyway runs during context refresh (Spring Boot autoconfig + the
# migrationStartupRunner FlywayMigrationStrategy), BEFORE the app reports
# readiness — so migration is inherently readiness-gated (D5): a failed or
# in-progress migration can never serve traffic.
#
# The three options below are PINNED, not env-driven: they are forbidden under
# prod (D2/D4) and a static pin means a future Flyway/Spring Boot default flip
# cannot silently re-enable them (same reasoning as spring.mvc.problemdetails.
# enabled above). FlywayProdSafetyValidator fails the boot (exit 71) if any
# per-environment override re-enables them under the prod profile.
# ---------------------------------------------------------------------------
flyway:
# false: never auto-baseline an existing schema — keep the missing-migration
# safety net (FLYWAY-C6). Enabling under prod is forbidden (D4).
baseline-on-migrate: false
# false: reject out-of-order migrations — preserve cross-developer ordering
# consistency (FLYWAY-C5). Enabling under prod is forbidden (D4).
out-of-order: false
# true: keep Flyway `clean` (drops the whole schema) disarmed. Disabling this
# under prod is forbidden — clean is destructive.
clean-disabled: true
jpa:
hibernate:
# none | validate | update | create | create-drop
# prod accepts only none|validate; JpaSchemaSafetyValidator rejects schema-writing modes.
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
# true | false
show-sql: ${APP_DATASOURCE_SHOW_SQL}
# true | false (don't enable in prod)
open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW}
properties:
hibernate:
# true | false (paired with show-sql)
format_sql: ${APP_DATASOURCE_FORMAT_SQL}
security:
oauth2:
resourceserver:
jwt:
# OIDC issuer (e.g., Keycloak realm URL). Spring Boot resolves JWKS from
# /.well-known/openid-configuration at this URI.
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
# validated against the JWT `aud` claim; blank disables the check
audiences: ${APP_SECURITY_JWT_AUDIENCE}
main:
# off | console | log
banner-mode: ${SPRING_BANNER_MODE}
# true | false
lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION}
# true | false
log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO}
threads:
virtual:
# true | false (Java 21 virtual threads for Tomcat request handlers)
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
servlet:
session:
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
# 있으면 그 시점에 잃는다.
#
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
timeout: ${APP_SESSION_TIMEOUT:8h}
multipart:
# feature-api-contract-baseline D8: bound request body size so an oversized
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
# GlobalExceptionHandler#handleMaxUploadSizeExceededException), never a raw 500.
# Multipart-specific upload limits (UPLOAD_SIZE_EXCEEDED) are refined by
# feature-file-resource-handling-contract.
# Same placeholders as app.fileserver-platform.upload.*, deliberately. Two independent
# ceilings meant the servlet container rejected at 10MB whatever the Fileserver policy said,
# so a 100MB upload failed before any Fileserver code — including its error mapping — ran,
# and the configured limit described a policy nobody could reach.
max-file-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE:100MB}
max-request-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE:110MB}
# Jackson deserialization policy (feature-boundary-validation-mapping-contract B1).
# Every request DTO crosses this boundary; the switches make malformed payloads
# fail at the edge rather than silently coercing or dropping fields.
jackson:
deserialization:
# true | false (Jackson 2.13+ default; reject unknown JSON keys)
fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES}
# true | false (block JSON null → primitive 0/false coercion)
fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES}
# true | false (surface JSON containing fields the target @JsonIgnore'd)
fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES}
# Serialization output policy (feature-schema-serialization-contract D2/D3).
# These mirror current defaults but are pinned so a future Spring Boot default
# flip cannot silently break the datetime / decimal wire contract — the same
# reasoning as spring.mvc.problemdetails.enabled above.
datatype:
enum:
# true | false (false = Jackson default; unknown enum -> throw, not null)
read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL}
datetime:
# true | false (false: java.time -> ISO-8601 string via JavaTimeModule, D2)
write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS}
lifecycle:
# duration: 30s | 1m | 500ms
timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT}
# ---------------------------------------------------------------------------
# Actuator / management — MERGED ownership at integration:
# - feature-management-actuator-security-contract owns server.port, the exposure
# allowlist/exclude, health show-details, shutdown-disabled, and info.
# - feature-runtime-health-lifecycle-contract owns the health probe SHAPE
# (probes.enabled + the liveness/readiness/startup groups + dependency taxonomy).
# ---------------------------------------------------------------------------
management:
# feature-management-actuator-security-contract D1: separate management port so actuator
# endpoints are not exposed on the same socket as the app API. Default: 9001.
server:
port: ${MANAGEMENT_SERVER_PORT:9001}
endpoints:
web:
# D2: production allowlist — only safe, scrape-friendly endpoints are exposed.
exposure:
include: health,prometheus,info,loggers
# D2/D4/D5: explicitly excluded dangerous endpoints (env leaks secrets,
# heapdump/threaddump = memory forensics, shutdown = remote kill, configprops = secret leak).
exclude: env,configprops,heapdump,threaddump,shutdown
endpoint:
health:
# D8: never expose health details to unauthenticated callers.
show-details: when-authorized
# Membership is validated. With validation off, a group naming a contributor that does not
# exist is silently dropped and readiness reports UP while proving nothing about the
# dependency it claims to gate on — a false green that survives exactly as long as nobody
# checks. On, the cost is that every name below must resolve, in every deployment.
#
# That cost is paid by construction rather than by convention: the groups below name only
# unconditional contributors, and the one conditional member — `redisRequired` — is appended
# by RedisReadinessGroupPostProcessor from the same predicate that creates the bean. A
# contributor can therefore be named only where it exists, and a misspelled one still fails
# startup.
validate-group-membership: true
# feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready
# liveness/readiness/startup probe paths.
probes:
enabled: true
group:
# Liveness: JVM can continue (OOM → liveness DOWN → pod restart).
# Only livenessState is included; dependency health MUST NOT be here —
# a DOWN DB must never trigger a pod restart when the JVM is healthy.
liveness:
include: livenessState
# Readiness: ready to serve traffic AND all REQUIRED dependencies up.
# Every name here must resolve to a contributor that exists (validate-group-membership
# above). A correctness-role Redis contributor belongs in this list and is added when the
# Redis runtime composition creates one; naming it before then produced a readiness probe
# that reported UP without ever checking Redis.
# `redisRequired` is NOT listed here, and must not be: group membership does not tolerate a
# conditional contributor being absent. Boot validates the group against the contributors
# that exist, so naming it statically made every Redis-off and cache-only deployment fail at
# startup with "Included health contributor 'redisRequired' in group 'readiness' does not
# exist". RedisReadinessGroupPostProcessor appends it to this list — from the same predicate
# that creates the bean — when Redis is on and a correctness role selected it.
# `redisOptional` is never appended: a cache outage is reported as degraded detail and never
# turns a healthy JVM or an otherwise-ready pod unavailable.
readiness:
include: readinessState,db
# Startup: startup/migration validation complete.
# readinessState acts as the startup completion gate — it flips UP only
# after the context is fully initialized (Flyway migration included).
startup:
include: readinessState
shutdown:
# D4: shutdown endpoint disabled globally — even if somehow exposed, it cannot be invoked.
access: none
info:
build:
# Build-info only (no env leak).
enabled: true
server:
# integer 1-65535
port: ${APP_SERVER_PORT}
# graceful | immediate
shutdown: ${APP_SERVER_SHUTDOWN}
# none | native | framework (trust X-Forwarded-* behind LB/proxy)
forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY}
tomcat:
threads:
# integer >= 1
max: ${APP_SERVER_TOMCAT_MAX_THREADS}
# integer >= 0
min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS}
# integer >= 0 (OS-level backlog queue depth)
accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT}
# integer >= 1 (hard cap on simultaneously open connections)
max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS}
# duration: 20s | 1m
connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT}
compression:
# true | false
enabled: ${APP_SERVER_COMPRESSION_ENABLED}
# bytes or sized: 1024 | 1KB | 2KB
min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE}
logging:
level:
# TRACE | DEBUG | INFO | WARN | ERROR | OFF
root: ${APP_LOG_LEVEL_ROOT}
dev.caskeleton: ${APP_LOG_LEVEL_APP}
org.springframework: ${APP_LOG_LEVEL_SPRING}
org.springframework.web: ${APP_LOG_LEVEL_WEB}
# DEBUG here prints SQL once JPA/jdbc is wired in
org.hibernate.SQL: ${APP_LOG_LEVEL_SQL}
# Module-scoped knobs. Each block is bound into a *Settings @ConfigurationProperties
# record in the corresponding module, which is where allowed-value validation lives.
ca-skeleton:
# Canonical HTTP capability activation. Bindings are the sole activation SSOT: provider
# definitions alone are inert, and the current NOT_IMPLEMENTED readiness card rejects ACTIVE
# before any client/executor/pool resource can be created.
capabilities:
# Every capability below renders its keys under one namespace — app.redis.namespace, which is
# {environment}:{service}:{domain}. The per-capability namespace-application /
# namespace-environment pairs that used to live here are gone: four capabilities each joining
# two free-form tokens in their own order produced four different key prefixes, and the ACL
# pattern meant to fence the deployment in matched none of them.
cache:
# Canonical semantic cache activation. Disabled by default; "redis" composes
# RedisCacheRegionAdapter for the region named below and resolves HMAC material by reference.
bindings:
default: ${APP_CACHE_CANONICAL_DEFAULT_PROVIDER:disabled}
semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default}
key-version: 1
key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET
command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:200ms}
positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:30s}
positive-hard-ttl: ${APP_CACHE_REDIS_POSITIVE_HARD_TTL:5m}
negative-ttl: ${APP_CACHE_REDIS_NEGATIVE_TTL:10s}
# Floor on the hard TTL. Below it, entries expire faster than the round trip that wrote them:
# every read misses and every miss writes.
minimum-hard-ttl: ${APP_CACHE_REDIS_MINIMUM_HARD_TTL:1s}
idempotency:
# disabled | jdbc | redis. JDBC is the existing V1 provider; Redis is the owner-safe V2
# provider. They are mutually exclusive and no V1-to-V2 facade is inferred.
provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc}
key-hmac-secret-reference: secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET
key-version: 1
command-timeout: ${APP_IDEMPOTENCY_REDIS_COMMAND_TIMEOUT:200ms}
processing-lease: ${APP_IDEMPOTENCY_PROCESSING_LEASE:30s}
replay-ttl: ${APP_IDEMPOTENCY_TTL:24h}
failure-retention: ${APP_IDEMPOTENCY_FAILURE_RETENTION:24h}
response-codec-id: json-v2
# Numeric: the owner-safe claim writes this into the record and compares it there.
policy-revision: 2
lease:
# disabled | redis. This is EFFICIENCY_ONLY and never supplies fencing.
provider: ${APP_LEASE_PROVIDER:disabled}
key-hmac-secret-reference: secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET
key-version: 1
command-timeout: ${APP_LEASE_REDIS_COMMAND_TIMEOUT:200ms}
contention-retry-after: ${APP_LEASE_REDIS_CONTENTION_RETRY_AFTER:50ms}
# How much shorter than the server's TTL this holder considers its lease valid. The two
# clocks are not the same clock, and a holder that measured the full TTL locally would still
# believe it held a lease the server had already handed to somebody else.
drift-budget: ${APP_LEASE_REDIS_DRIFT_BUDGET:10ms}
rate-limit:
# disabled | redis. This is the sole outbound provider activation selector.
provider: ${APP_RATE_LIMIT_PROVIDER:disabled}
failure-policy: ${APP_RATE_LIMIT_FAILURE_POLICY:fail-closed}
default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default}
failure-retry-after: ${APP_RATE_LIMIT_FAILURE_RETRY_AFTER:100ms}
key-version: ${APP_RATE_LIMIT_KEY_VERSION:1}
command-timeout: ${APP_RATE_LIMIT_REDIS_COMMAND_TIMEOUT:200ms}
policies:
api-default:
revision: ${APP_RATE_LIMIT_POLICY_REVISION:v1}
algorithm: ${APP_RATE_LIMIT_ALGORITHM:sliding-counter}
limit: ${APP_RATE_LIMIT_LIMIT:100}
window: ${APP_RATE_LIMIT_WINDOW:1s}
capacity: ${APP_RATE_LIMIT_CAPACITY:100}
refill-tokens: ${APP_RATE_LIMIT_REFILL_TOKENS:100}
refill-period: ${APP_RATE_LIMIT_REFILL_PERIOD:1s}
maximum-cost: ${APP_RATE_LIMIT_MAXIMUM_COST:10}
cleanup-grace: ${APP_RATE_LIMIT_CLEANUP_GRACE:5s}
maximum-clock-regression: ${APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION:250ms}
security:
bootstrap:
# required, non-blank — startup fails if blank (see BootstrapSettings)
app-name: ${APP_NAME}
runtime:
# feature-env-driven-runtime-configuration D8: enforced at startup by
# StartupSafetyValidator. The two prod-unsafe toggles fail startup if true under
# the prod profile; multi-instance fails startup if its coordination beans are absent.
# true | false (internal error detail in responses — forbidden under prod)
error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false}
# true | false (request/response body capture in logs — forbidden under prod)
log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false}
# true | false (requires the 5 instance-coordination beans when true)
multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false}
# true | false (whether to run Flyway migrations automatically at startup)
migration-on-startup: ${APP_MIGRATION_ON_STARTUP:true}
# feature-distributed-lock-contract D5 — distributed lock acquisition contract (try-lock +
# finite wait + lease TTL). Bound to LockSettings (adapter-persistence). Plain values, NOT
# APP_* env keys — new env-key registration is feature-env-driven-runtime-configuration's
# domain (out of scope for this branch); code defaults in LockSettings mirror these.
lock:
wait-time: 3s
lease-ttl: 30s
# Which RDBMS vendor composition the persistence adapter registers: postgresql | h2. Bound to
# PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup.
#
# A property rather than a profile check, because the vendor belongs to the datastore and not to
# the environment that happens to use it. The profiles then state their choice:
# application-local.yml selects h2, application-dev.yml and application-prod.yml select
# postgresql, and prod additionally refuses h2 through PersistenceVendorProdSafetyValidator.
#
# The value here is the default for a deployment that activates no profile at all — PostgreSQL,
# the vendor every deployment ran before this selector existed.
persistence:
vendor: postgresql
# JPA named-policy deadline envelope. JpaTransactionSettings validates the hierarchy;
# SpringPolicyTransactionPort intersects these limits with the caller's absolute CallBudget
# and the actual Hikari connection timeout before acquiring a transaction.
jpa:
transaction:
transaction-timeout: 30s
begin-budget: 250ms
minimum-action-window: 1s
completion-margin: 500ms
statement-timeout: 10s
lock-timeout: 2s
idle-guard-timeout: 15s
transaction-margin: 250ms
lock-margin: 100ms
retry-base-delay: 10ms
retry-maximum-delay: 50ms
retry-maximum-attempts: 2
presentation:
# feature-api-contract-baseline D2: API version prefix. Default is the URI
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
# no prefix. The supplemental "X-Api-Version" header never overrides the path.
api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}
techlog:
studio:
# Studio 문서 목록 커서 서명 키. 값이 없거나 16바이트 미만이면 StudioSettings가 경고하고 개발용
# 값으로 대체한다 — 커서에 권한이 실리지 않아 부팅을 막을 사유는 아니지만, 인스턴스마다 값이
# 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
cursor-signing-key: ${APP_STUDIO_CURSOR_SIGNING_KEY:}
# 검증 결과가 유효한 기간(studio_validation.valid_until).
validation-ttl: ${APP_STUDIO_VALIDATION_TTL:1h}
# 미리보기가 유효한 기간(studio_preview.expires_at).
preview-ttl: ${APP_STUDIO_PREVIEW_TTL:24h}
idempotency:
# feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h,
# validated in IdempotencyProperties); reaper-interval is literal operational tuning.
ttl: ${APP_IDEMPOTENCY_TTL}
reaper-interval: 10m
async:
# feature-background-job-async-contract D7 — @Async ThreadPoolTaskExecutor pool sizing.
# Registry SSOT: docs/registries/env-keys.yaml (APP_ASYNC_EXECUTOR_* rows 1337-1377).
# Bound into AsyncExecutorSettings @ConfigurationProperties(prefix = "ca-skeleton.async.executor").
# Bounded queue is mandatory (D7) — an unbounded queue makes max-size unreachable.
executor:
# int >= 1 (positive_int). Always-alive worker count.
core-size: ${APP_ASYNC_EXECUTOR_CORE_SIZE:10}
# int >= core-size (positive_int_ge_core). Hard ceiling on workers.
max-size: ${APP_ASYNC_EXECUTOR_MAX_SIZE:50}
# int in 1..<Integer.MAX_VALUE (positive_int_bounded). Bounded backlog depth.
queue-capacity: ${APP_ASYNC_EXECUTOR_QUEUE_CAPACITY:200}
outbox:
# feature-domain-event-outbox-contract I11 — all six values are literal defaults;
# NO env placeholders (spec: 신규 env key 없음). Bound into OutboxProperties.
# true | false — enable/disable the relay scheduler (OutboxRelayScheduler)
relay-enabled: true
# ISO-8601 duration — how often the relay polls for pending rows
poll-interval: PT5S
# integer >= 1 — maximum rows claimed per relay cycle
batch-size: 20
# ISO-8601 duration — IN_FLIGHT orphan visibility window (I6: next_attempt_at reuse)
in-flight-timeout: PT5M
# ISO-8601 duration — read also by adapter-persistence OutboxReaper @Scheduled
reaper-interval: PT10M
# ISO-8601 duration — PUBLISHED row retention before reaper deletes them (I3)
# read also by adapter-persistence OutboxReaper via ${ca-skeleton.outbox.published-retention:P7D}
published-retention: P7D
security:
# jwt | redis-session; the bootstrap composition validator rejects mixed infrastructure.
auth-mode: ${APP_SECURITY_AUTH_MODE:jwt}
# Required only in jwt mode.
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
# blank to skip audience check
audience: ${APP_SECURITY_JWT_AUDIENCE}
# comma-separated list (Spring binds to List<String>)
public-paths: ${SECURITY_PUBLIC_PATHS}
session:
cookie-name: ${APP_SESSION_COOKIE_NAME:CA_SESSION}
secure: ${APP_SESSION_COOKIE_SECURE:true}
http-only: ${APP_SESSION_COOKIE_HTTP_ONLY:true}
same-site: ${APP_SESSION_COOKIE_SAME_SITE:Lax}
path: ${APP_SESSION_COOKIE_PATH:/}
csrf-cookie-name: ${APP_SESSION_CSRF_COOKIE_NAME:XSRF-TOKEN}
csrf-header-name: ${APP_SESSION_CSRF_HEADER_NAME:X-XSRF-TOKEN}
authz:
# feature-authentication-authorization-contract D2/D3/D8: app-side role→permission
# mapping (the default source; IdP-issued permission claims are an out-of-scope
# alternative). Keys are RAW IdP role names (no ROLE_ prefix — that prefix only
# exists on Spring authorities, not on the principal's raw role set), looked up
# case-insensitively. Values are explicitly enumerated `resource:action` permissions
# (no wildcards — least-privilege, OWASP-AUTHZ-C4). Product-specific roles belong here.
role-permissions: {}
cors:
# true | false
enabled: ${APP_SECURITY_CORS_ENABLED}
# comma-separated
allowed-origins: ${APP_SECURITY_CORS_ORIGINS}
# comma-separated; empty -> defaults
allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS}
# comma-separated; "*" allows any
allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS}
# true | false
allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS}
# seconds
max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE}
logging:
file:
# true | false (wraps console + adds rolling JSON file appender)
enabled: ${APP_LOG_FILE_ENABLED}
# relative (to src/) or absolute
path: ${APP_LOG_FILE_PATH}
# size with unit: KB | MB | GB
max-size: ${APP_LOG_FILE_MAX_SIZE}
# integer >= 1
max-history: ${APP_LOG_FILE_MAX_HISTORY}
# size with unit or 0
total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP}
async:
# true | false (AsyncAppender wrapper for non-blocking I/O)
enabled: ${APP_LOG_ASYNC_ENABLED}
# integer >= 1
queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE}
# integer >= 0 (0 = never drop)
discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD}
json:
# IANA timezone (UTC | Asia/Seoul | ...) or "default"
timezone: ${APP_LOG_JSON_TIMEZONE}
# ISO 8601 pattern
timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN}
# true | false (file/method/line — expensive)
include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA}
# integer; 0 = full name, positive = abbreviated
logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH}
# float in [0.0, 1.0] — keep-probability for <=INFO logs (prod 0.1 = 10% sampling;
# WARN/ERROR always 100%). Consumed by SamplingTurboFilter via logback <springProperty>.
sampling-rate: ${APP_LOG_SAMPLING_RATE}
privacy:
# secret-tier HMAC salt for user_principal pseudonymization (DRIFT-6; algorithm SSOT
# feature-data-retention-privacy-contract). Blank -> PrivacySettings warns + uses a dev
# sentinel; prod MUST supply a real secret-manager value.
pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:}
# feature-distributed-tracing-contract D1/D4/D6 — tracing seam settings.
# The OTel/Micrometer tracer runtime is active in this repo (Plan A — seam activated):
# micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp are wired in
# app-bootstrap/build.gradle. The exporter remains off while OTEL_EXPORTER_OTLP_ENDPOINT
# is blank (url_or_empty default). A fork can supply the endpoint via env or override
# SpanErrorRecorder with its own bean.
# D4 disabled-fallback: even when enabled=false, RequestLoggingFilter always
# generates a W3C traceparent so meta.traceId and log trace_id are never null.
# D6 per-profile defaults: prod=0.01 / staging=0.10 / dev·local=1.0.
# APP_TRACING_SAMPLE_RATE overrides the per-profile default when set.
tracing:
# true | false (boolean_strict). Tracing seam on/off.
enabled: ${APP_TRACING_ENABLED:true}
# float in [0.0, 1.0]. Per-profile override (D6 float_between_0_and_1 validation at startup).
# blank = per-profile default via TracingSampleRateResolver (D-1 ISSUE-1 fix).
sample-rate: ${APP_TRACING_SAMPLE_RATE:}
exporter:
# url_or_empty: blank = exporter off (D1 SEAM default); non-blank must be a valid URL.
otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:}
# feature-integration-adapter-templates — optional integration adapter toggles.
# The `app.<domain>.<adapter>.enabled` flags feed Spring @ConditionalOnProperty (Layer 1):
# disabled (default): messaging binds a fail-fast Disabled* sentinel bean; cache/notification
# contribute nothing and fail fast in their router (CacheStoreRouter / RoutingNotifier) on
# unbound access. Enabled registers the real adapter
# (which needs its project-supplied integration client bean). Domain namespace, NOT a
# generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT.
app:
# The one global Redis switch. False (the default) means no Redis settings are bound, no Redis
# credential is required, no client, connection, thread or health contributor is created, and no
# Redis-specific resource is read. There is deliberately no second master switch: a role such as
# cache or session selects *which* Redis capabilities compose, never *whether* Redis exists.
redis:
enabled: ${APP_REDIS_ENABLED:false}
# Fileserver R2 exact destination/provider composition. Disabled by default: while false,
# these blank attestation placeholders do not create directories, probe a filesystem, or
# contribute FilePublicationPort. Enabling fails closed unless every local-persistent
# attestation value matches the pre-provisioned root. No implicit local fallback exists.
fileserver:
enabled: ${APP_FILESERVER_ENABLED:false}
destinations:
local-export:
provider-ref: local-primary
required-publication: unique-atomic-create
required-durability: file-and-directory-sync
maximum-rows: 1000000
maximum-encoded-bytes: 1073741824
providers:
local-primary:
# local-persistent is the only implemented/qualified R2 provider.
# shared-mounted/NFS and SFTP settings must not be added before their providers exist.
type: local-persistent
root-directory: ${APP_FILESERVER_LOCAL_ROOT:}
auto-create: false
strict-path-security: true
expected-file-store-name: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME:}
expected-file-store-type: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE:}
mount-sentinel-name: .ca-fileserver-volume
mount-sentinel-sha256: ${APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256:}
expected-owner: ${APP_FILESERVER_LOCAL_EXPECTED_OWNER:}
maximum-root-mode: "0750"
# HTTP Fileserver platform — a different capability from app.fileserver above, which publishes
# tabular exports. This one serves upload/download/lifecycle routes over HTTP and owns its own
# namespace so the two cannot be switched on by accident together.
#
# While enabled=false none of the detail below is bound: FileserverPlatformAutoConfiguration
# binds the block itself, and it is not processed until the master switch is true. Unknown keys
# under this prefix are refused rather than ignored.
# Outbound HTTP Client platform. Only the master switch lives here.
#
# The per-client surface is an indexed list, and templating one element would materialise a
# nameless client in every deployment — which the settings' own aggregate validation refuses, so
# the template could not be left in place. Clients are therefore declared straight from the
# environment as APP_HTTPCLIENT_CLIENTS_0_*, registered in
# docs/httpclient/env-fields.yaml, and an APP_HTTPCLIENT_ variable absent from that
# registry fails startup rather than being ignored.
#
# While enabled=false none of it is bound: HttpClientPlatformAutoConfiguration binds the block
# itself and is not processed until the master switch is true.
httpclient:
enabled: ${APP_HTTPCLIENT_ENABLED:false}
fileserver-platform:
enabled: ${APP_FILESERVER_PLATFORM_ENABLED:false}
# Writer-lease owner. Must be unique per instance in a multi-instance deployment; the startup
# gate treats the default as single-instance.
instance-id: ${APP_FILESERVER_PLATFORM_INSTANCE_ID:local-node}
default-namespace: ${APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE:default}
storage:
# Absolute, on its own volume, and never under a web or configuration root. A relative path
# resolves against the process working directory, which differs between a container and a
# test, so it is refused.
root: ${APP_FILESERVER_PLATFORM_STORAGE_ROOT:/var/lib/backend/files}
publish-mode: ${APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE:atomic-move-preferred}
buffer-size: ${APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE:128KB}
forbidden-root-ancestors: ${APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS:/app,/etc,/usr/share/nginx/html}
upload:
# Shared with spring.servlet.multipart below through the same placeholder. Two independent
# limits would let the servlet container reject an upload the Fileserver policy allows,
# before any Fileserver code — including its error mapping — ever runs.
max-file-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE:100MB}
max-request-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE:110MB}
initial-reservation: ${APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION:8MB}
max-parts: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS:16}
ttl: ${APP_FILESERVER_PLATFORM_UPLOAD_TTL:1h}
reservation-ttl: ${APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL:24h}
lease-duration: ${APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION:30s}
require-content-length: ${APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH:false}
download:
cache-control: ${APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL:private, no-store}
inline-allowed: ${APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED:false}
max-ranges: ${APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES:1}
# Applies to the single-range profile too, so the ceiling is not inert by default.
max-range-bytes: ${APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES:100MB}
zero-copy-enabled: ${APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED:true}
zero-copy-minimum-bytes: ${APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES:16MB}
transfer:
core-size: ${APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE:8}
max-size: ${APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE:32}
queue-capacity: ${APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY:64}
await-seconds: ${APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS:300}
security:
# required | role-based | unenforced. `required` has no built-in policy and fails startup
# unless the deployment supplies a FileAccessPolicy bean; `unenforced` is refused under a
# production profile.
access-policy: ${APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY:required}
read-roles: ${APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES:ROLE_FILE_READ}
write-roles: ${APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES:ROLE_FILE_WRITE}
admin-roles: ${APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES:ROLE_FILE_ADMIN}
verification:
timeout: ${APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT:5s}
require-media-type-verdict: ${APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT:false}
inline-safe-profile: ${APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE:false}
quota:
instance-upload-permits: ${APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS:16}
scope-upload-permits: ${APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS:4}
direct-download-permits: ${APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS:64}
soft-high-water: ${APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER:0.70}
hard-high-water: ${APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER:0.85}
admin:
enabled: ${APP_FILESERVER_PLATFORM_ADMIN_ENABLED:false}
orphan-minimum-age: ${APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE:1h}
cleanup:
enabled: ${APP_FILESERVER_PLATFORM_CLEANUP_ENABLED:false}
interval: ${APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL:60s}
max-items: ${APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS:100}
max-bytes: ${APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES:1GB}
retry-backoff: ${APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF:5m}
tus:
enabled: ${APP_FILESERVER_PLATFORM_TUS_ENABLED:false}
httpbis-draft12:
# Unratified protocol; the contract can change without notice.
enabled: ${APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED:false}
nginx:
enabled: ${APP_FILESERVER_PLATFORM_NGINX_ENABLED:false}
internal-prefix: ${APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX:/__files/}
object-suffix: ${APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX:.bin}
minimum-size: ${APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE:16MB}
observability:
metrics-enabled: ${APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED:true}
# Secret. Keyed HMAC over file identifiers; an unkeyed digest of an enumerable id is
# reversible, so startup fails while metrics are on and this is blank.
fingerprint-key: ${APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY:}
rate-limit:
# Inbound HTTP enforcement is a separate axis from outbound provider activation.
# enabled=true with no exact EdgeRateLimitPort fails fast; it never installs a local fallback.
enabled: ${APP_RATE_LIMIT_ENABLED:false}
default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default}
hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1}
caller-deadline-budget: 2s
# remote-addr-only | forwarded-headers-trusted (trusted ingress only)
client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only}
# Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default —
# forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis.
# A binding to a backend that is not enabled fails startup (Layer 3 moved to router).
messaging:
# Active message broker id (e.g. kafka). Blank = messaging disabled (fail-fast on use).
# Selects the single MessageBroker; adding a broker is new files only (MessagingConfig).
broker: ${APP_MESSAGING_BROKER}
kafka:
# CSV of host:port; required (non-empty) only when broker=kafka. Bound +
# validated by KafkaAdapterSettings (format) + KafkaAdapterConfig (required-when-active).
brokers: ${APP_MESSAGING_KAFKA_BROKERS:}
notification:
# Active provider id per kind; blank = that kind disabled (fail-fast on use).
# Add a provider = new files only (NotificationConfig); select it here.
slack:
provider: ${APP_NOTIFICATION_SLACK_PROVIDER}
email:
provider: ${APP_NOTIFICATION_EMAIL_PROVIDER}