# Consensus revision — open Korean prose, strong validation, canonical fallback

```yaml
ralplan_metadata:
  artifact_kind: revision
  requested_stage: 3
  fallback_author: planner
  review_inputs: [stage-01-planner, stage-02-architect, stage-02-critic]
  implementation_authority: none
```

## Binding decision

The approved choice remains genuinely open-ended Korean connective prose around locally expanded semantic anchors, with strong deterministic validation and immediate canonical fallback. It is not a finite variant catalog, fixed lexicon, template selection, semantic theorem, or whole-body rewrite. Facts, values, dates, predicates/decisions, actions, safety, approval/delivery claims, headings, buttons, envelopes, layout, and all non-slot bytes stay canonical/local. The feature is presentation-only, default-off, and never changes consent, activation, routing, Gate-D, proposal/revision/epoch, approval, delivery authority, or provider sends. Kimi/Moonshot, generic provider resolvers, auxiliary providers, OAuth, retries, repairs, pools, and generic conversation entry are excluded.

`gateway/platforms/korean_expression.py` is the sole feature module. It imports neither profile-local `checkin_cli` nor a generic/auxiliary provider resolver. It receives typed local manifests only; all placeholder expansion happens locally after whole-response validation. A failure yields canonical bytes, or no edit/send if the surface’s own authority record cannot be committed.

## Exact feature YAML contract

The only key is `platforms.telegram.extra.korean_expression_postprocessor`; absence is disabled. No aliases, normalization, environment overrides, or unknown keys are accepted.

* Every mapping key is an exact string; Boolean means `type(value) is bool`.
* Disabled has exactly `schema_version: 1`, `mode: disabled`, and `surfaces`; `provider` is forbidden and all four surface Booleans are false.
* Shadow/enabled have exactly `schema_version`, `mode`, `surfaces`, and `provider`; mode is literal `shadow` or `enabled`, and at least one surface is true.
* `surfaces` has exactly Boolean `daily`, `weekly`, `adaptive_dualcoachtest`, `adaptive_physique`.
* `provider` has exactly `id: openai-direct`, `transport: chat_completions`, `endpoint: https://api.openai.com/v1`, `model: gpt-4.1-mini`, `max_output_tokens: 160` (int), and `timeout_seconds: 3` (int). Endpoint/model compare byte-for-byte: a trailing slash, case variation, alternate URL/model, or extra field is invalid. API keys are never YAML.

```yaml
# Exact default in both real profiles after cutover.
platforms:
  telegram:
    extra:
      korean_expression_postprocessor:
        schema_version: 1
        mode: disabled
        surfaces:
          daily: false
          weekly: false
          adaptive_dualcoachtest: false
          adaptive_physique: false
```

```yaml
# Exact controlled-shadow profile shape.
platforms:
  telegram:
    extra:
      korean_expression_postprocessor:
        schema_version: 1
        mode: shadow
        surfaces:
          daily: true
          weekly: true
          adaptive_dualcoachtest: false
          adaptive_physique: true
        provider:
          id: openai-direct
          transport: chat_completions
          endpoint: https://api.openai.com/v1
          model: gpt-4.1-mini
          max_output_tokens: 160
          timeout_seconds: 3
```

```yaml
# Exact single-surface enabled promotion shape.
platforms:
  telegram:
    extra:
      korean_expression_postprocessor:
        schema_version: 1
        mode: enabled
        surfaces:
          daily: true
          weekly: false
          adaptive_dualcoachtest: false
          adaptive_physique: false
        provider:
          id: openai-direct
          transport: chat_completions
          endpoint: https://api.openai.com/v1
          model: gpt-4.1-mini
          max_output_tokens: 160
          timeout_seconds: 3
```

At `TelegramAdapter.__init__`, absent/valid-disabled installs a no-attempt coordinator: no client and no attempt/audit row. Any malformed disabled/shadow/enabled block installs canonical-only `CONFIG_INVALID`, emits only a static redacted startup reason, creates no client/row, and does not take down unrelated Telegram functions; cutover audit returns not-ready. Valid shadow/enabled still constructs the client lazily only after its surface-owned durable claim.

| phase | physique-coach true surfaces | dualcoachtest true surfaces | gate |
|---|---|---|---|
| P0 | disabled / none | disabled / none | strict audit + wrappers |
| P1 isolated controlled shadow | daily, weekly, adaptive_physique | weekly, adaptive_dualcoachtest | 48/48 synthetic calls |
| P2 test-profile shadow | daily, weekly, adaptive_physique | weekly, adaptive_dualcoachtest | 14 days, >=10 accepted/zero failure per surface |
| P3 | enabled daily only | disabled | daily approval |
| P4 | enabled weekly only | enabled weekly only | separate profile evidence |
| P5 | enabled adaptive_physique only | enabled adaptive_dualcoachtest only | callback + typed-note evidence |

Unlisted flags are false; each P3–P5 is a separate human-approved config/restart promotion. None grants Gate-D, activation, delivery, or approval.

## Sealed direct OpenAI transport

Before construction require nonempty `OPENAI_API_KEY` and reject the attempt with `credential_environment_rejected` if any uppercase/lowercase `OPENAI_ORG_ID`, `OPENAI_PROJECT_ID`, `OPENAI_WEBHOOK_SECRET`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `OPENAI_ORGANIZATION`, `OPENAI_PROJECT`, `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, or `NO_PROXY` exists. The key is read once and never logged/persisted. This rejects SDK ambient organization/project/webhook/base URL and all ambient proxy routing.

The only factory is equivalent to:

```python
ctx = ssl.create_default_context(cafile=certifi.where())
assert ctx.verify_mode is ssl.CERT_REQUIRED and ctx.check_hostname
transport = PinnedHttpsTransport(
    httpx.HTTPTransport(retries=0, verify=ctx),
    allowed_origin=("https", "api.openai.com", 443),
    allowed_path="/v1/chat/completions",
)
http_client = httpx.Client(
    transport=transport, trust_env=False, follow_redirects=False,
    timeout=httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),
)
client = openai.OpenAI(
    api_key=api_key, base_url="https://api.openai.com/v1",
    organization=None, project=None, webhook_secret=None,
    max_retries=0, http_client=http_client,
)
```

The transport rejects before I/O every non-HTTPS scheme, non-`api.openai.com:443` host/port, or path other than `/v1/chat/completions`; it delegates with `retries=0`. TLS is verified with certifi; redirects are never followed. For an owned, unexpired claim exactly one non-streaming `chat.completions.create` is made: literal `gpt-4.1-mini`, `n=1`, JSON object response, `max_completion_tokens=160`, `store=False`, no tools/stream/repair. Request bytes are <=6,144; accept only reported usage `prompt_tokens<=600`, `completion_tokens<=160`, `total_tokens<=760`; missing/over-budget usage is canonical. The durable-claim worker deadline is 4 seconds, client timeout 3 seconds. Any cancellation, timeout, 3xx/4xx/5xx, malformed/late result, or failure gets no retry.

Hostile-environment tests set every named OpenAI/proxy variable, assert no client/request; separate mocked-transport tests assert `trust_env=False`, required TLS/check-hostname, redirects terminal, endpoint pinning, no org/project headers, and exactly one request/no hidden retry.

## Private wire and validation contract

The provider receives no canonical body, expansion, customer/session/proposal/schedule ID, name/address, note/history, measurement/date, raw fact/action, destination, or telemetry. Per attempt, a random 128-bit nonce derives opaque aliases with a 0600 key:

```
l_<base32(HMAC(key, b"l\0" + nonce + ordinal))[:20]>
s_<base32(HMAC(key, b"s\0" + nonce + ordinal))[:20]>
a_<base32(HMAC(key, b"a\0" + nonce + ordinal))[:20]>
⟦p_<base32(HMAC(key, b"p\0" + nonce + ordinal))[:20]>⟧
```

Every local slot/semantic/action/placeholder ID is replaced; aliases never contain a field name, state, decision, action, safety word, or value. Artifact key is a length-prefixed internal-identity HMAC and opaque. Request shape is exactly:

```json
{"schema_version":"kexp-wire-1","surface":"daily.v1","artifact_key":"k_<opaque>","slots":[{"slot":"l_<opaque>","semantic":["s_<opaque>"],"actions":["a_<opaque>"],"placeholders":["⟦p_<opaque>⟧"],"limit":120}]}
```

Response keys are exactly `schema_version`, `surface`, `artifact_key`, `slots`; each slot keys are exactly `slot`, `semantic`, `actions`, `placeholders`, `generated_ko`, echoing issued aliases once and in order. Serialized-request privacy tests forbid every local label, raw identity/value, and domain term.

Strict JSON uses UTF-8 plus `object_pairs_hook=reject_duplicates` at every depth. Reject >16 KiB, BOM, duplicate/missing/extra/wrong-type keys, non-NFC, wrong version/surface/key, unissued/reordered alias, Cc/Cf/Co controls (including bidi, zero-width, soft hyphen, variation selectors), CR/LF/tab/surrogate, URL, Latin identifier, bracket/tag syntax, and numeric/date/unit forms after exact placeholder masking. Generated prose allows only Hangul/Jamo, Hanja, ASCII space, and `.,!?…·:;()'\"-`; this keeps wording open, not finite. It also rejects normalized global/surface Korean stems/phrases for medical/diagnostic/prescription, safety/emergency, factual/evidential, quality/adherence, decision/adjustment/action, approval/hold/delivery claims. Inflection/synonym attacks are tested; the ADR calls this strong best effort, not a Korean semantic proof.

Maximum generated characters: daily 120; weekly interpretation/rationale 180 each; dual decision 120 and each reason 90; physique notice 120. Maximum expanded characters: 450, 600, 600, 450, 350, 450 respectively. After local expansion, byte identity outside slots, frozen headings/blank lines/envelopes/buttons, Telegram UTF-16 bounds, and no new numeric/date/unit token outside protected canonical spans are required. One invalid slot rejects the whole candidate.

## Typed surface schemas and entry points

| surface | typed local schema and slot | replay |
|---|---|---|
| `daily.v1` | `DailyPresentationV1(finalized_event_id, branch=complete|partial)`, one status paragraph between immutable facts and `오늘 할 일` | first save only; duplicate/explicit/restart replay canonical/no-call |
| `weekly.v1` | `WeeklyPresentationV1(report_schema, interpretation_enum, rationale_enum)`, typed interpretation/rationale paragraphs | durable prepared body only |
| `adaptive.dualcoachtest.card.v1` | `DualCoachCardPresentationV1(schema_version="dualcoachtest.operator-card.v1", decision_code, reason_codes, frozen_segments, canonical_payload)`, full `현재 판단:` value plus each code-mapped `검토 필요` reason | persisted payload only |
| `adaptive.physique.card.v1` | `PhysiqueCardPresentationV1(schema_version="physique.operator-card.v1", review_decision_code, frozen_segments, canonical_payload)`, `검토 안내` before exact final status | persisted payload only |

Both profile `adaptive_nutrition.py` copies add `presentation_segments_v1()` / `apply_presentation_segments_v1()`: they consume those schemas, verify typed proposal/revision/source pins, freeze all non-slots, and reject unknown decision/reason codes; regex over arbitrary card text is never authority. `telegram.py` calls profile adapters; the feature module sees only generic slots.

Both fresh adaptive producers share preparation: `_handle_adaptive_review_callback` retains internal-only `CardPresentationOrigin(kind="callback", source_session_id, callback_data, origin_message_id)` for a `status="card"` callback result; `AdaptiveOperatorService.handle_text` returns the same internal-only origin after consuming an `edit_note` session, and `_reserve_adaptive_review_update` passes it to `_send_adaptive_operator_result`. Origin never serializes/logs. Menu/view/terminal/recovery/untyped text is canonical/no-call.

## Surface-owned durability and restart protocol

There is no cross-store feature journal. `data/korean-expression-v1/audit-YYYY-MM.jsonl` may only best-effort mirror authoritative terminals as `{schema_version, occurred_at_utc, surface, mode, opaque_attempt_id, outcome_code}`. It contains no text, IDs, aliases, sizes, binding, destination, provider/model, exception, or customer data; failure is ignored after a static metric. It cannot claim, finalize, recover, or select output.

### Daily — finalized check-in event domain

Extend existing `EventType.COACHING_RESPONSE` with strict `DailyPresentationAttemptV1` and expose `claim_finalized_presentation`/`finalize_finalized_presentation` through `PhysiqueCheckinBridge`. Its exact payload is schema version, `finalized_event_id`, `surface="daily.v1"`, opaque attempt ID, phase `claimed|finalized`, deadline, and final outcome; no body/digest/value/alias/model result. Under the EventStore record lock, it validates the linked finalized event then append/fsyncs.

`absent -> claimed -> finalized`; final outcome is `selected`, `shadow_selected`, `canonical_provider_error`, `canonical_timeout`, `canonical_invalid`, `canonical_validation_rejected`, `canonical_claim_expired`, or `canonical_commit_error`. Claim fsync precedes the one model request. A duplicate active/final claim returns canonical and calls no model. Enabled selection finalizes/fsyncs `selected` before the Telegram edit; selected text remains memory-only. Shadow finalizes `shadow_selected` and edits canonical. Crash before/during call: restart appends `canonical_claim_expired` after lease expiry, no second call. Crash after final before edit: replay canonical, never recovers selected bytes. Final-append failure discards candidate and does canonical edit only if normal path is usable; otherwise no edit. A late worker rechecks exact unexpired attempt under EventStore lock or drops output. Edit failure never reopens/calls.

Keep an explicit `_render_physique_canonical_replay` (replacement for current replay): load `FinalizedCoachingArtifact`, call `_nutrition_daily_text(snapshot)` with no feedback, send canonical/no coordinator. `_render_physique_callback_prompt` is the sole first-save path; delete `_saved_physique_coaching_feedback`, `_generate_physique_coaching_feedback`, `_request_physique_coaching_feedback`, and generic resolver imports. Test first save, duplicate, explicit/restart replay, and generic-conversation non-entry.

### Weekly — schedule-delivery lock/record

In both `customer_schedule.py` copies/export, add `claim_customer_task_presentation`, `finalize_customer_task_presentation`, `expire_customer_task_presentation`, and `PinnedScheduledDelivery`. Existing `_schedule_lock`, ready fence, tombstone, chain, and fsync append are exclusive authority.

Schema v2 fields are immutable `canonical_body_digest`, `presentation_surface`, opaque `presentation_attempt_id`, deadline, and outcome. First state `presentation_claimed` writes exact canonical body/destination and all existing destination/registry/config/template pins only after tombstone fsync. Sole presentation transition is `presentation_claimed -> prepared`; it replaces body once with validated selected bytes and sets selected/shadow/canonical outcome, while every other pin and canonical-body digest stays identical. Existing v1 prepared rows are immutable canonical rows, never reprocessed.

For new weekly work: build canonical once, calculate pins, claim under schedule lock, then request. Finalize under that lock only after exact claim/deadline/pin checks. Only durable `prepared` proceeds to existing `mark_customer_task_sending`; never send memory bytes. Claim expiry/restart appends prepared canonical from the durable claimed row and calls no model. Ledger/tombstone/final failure sets existing recovery hard stop: no rerender/send/retry. Late result loses to prepared/expired.

```python
load_prepared_customer_task_delivery(
  profile_root, receipt, *,
  expected_registry_digest: str,
  expected_config_digest: str,
  expected_destination_digest: str,
) -> PinnedScheduledDelivery
```

The loader holds schedule lock, validates ready fence/full chain/tombstones, receipt identity and `prepared`, compares all three current pins, then returns exact durable body/destination plus body/template/canonical-body/registry/config/destination digests and outcome. In `_send_nutrition_coaching_tick`, an existing weekly `prepared` branch happens before either weekly report builder: refresh authority, calculate only current registry/config/destination digests, load, and use returned bytes. Mismatch is hard stop with zero rerender/model.

### Adaptive — operator publication lock

Extend `AdaptiveOperatorService` session state with `presentation_claimed`: `consumed -> presentation_claimed -> publish_pending -> publish_claimed -> published`. Disabled canonical flow may retain its direct `consumed -> publish_pending`; it is not a feature fallback. Claimed row stores canonical payload, schema/surface, opaque claim/deadline, origin kind, binding digest. `publish_pending` stores the single exact final payload/outcome. Both use `_authority_session_lock()` then `_publication_ledger_lock()` and current fsync ledger.

```python
claim_card_presentation(origin, *, canonical_payload, surface, binding_digest) -> CardPresentationClaim
finalize_card_presentation(origin, claim_id, *, candidate_or_none, canonical_reason) -> Mapping[str, object]
expire_card_presentation(origin, claim_id) -> Mapping[str, object]
```

Claim verifies just-consumed callback/typed-note session, origin message/provenance, card schema, proposal/revision, authority/config/registry/consent/activation/source/epoch/owner pins before append, then releases locks for one call. Finalizer reacquires both locks; it verifies exact unexpired claim, origin, current pins, canonical payload/schema/binding, reapplies and validates candidate inside lock, then appends exact `publish_pending`. Pin race/malformed/shadow finalizes canonical if possible; persistence failure makes no edit. Caller edits only returned durable payload then `mark_published`.

Crash before/during request leaves claim; expiry finalizes canonical under locks/no model. Crash after pending before edit uses existing pending-card recovery exact bytes/no model. Crash after Telegram edit uses existing receipt/unknown flow/no regeneration. Late worker must own current unexpired claim or drop. Tests cover both callback and typed-note producers on both profile grammars, same-candidate one commit, unequal conflict, and pin-race zero selected pending/edit.

## Migration, audit, privacy, and gates

`audit_physique_checkin_cutover(extra)` returns only `schema_version`, ready booleans (`legacy_feedback_removed`, `physique_bridge_valid`, `postprocessor_disabled_or_valid`), config SHA-256, and enum reason codes. `scripts/audit_physique_config_cutover.py` is read-only/no-follow, max 256 KiB, one YAML doc, depth <=32, nodes <=10,000. Its `StrictSafeLoader` rejects aliases/anchors/merge keys, non-string map keys, and duplicates after flattening at every nesting level; it never uses `yaml.safe_load`, writes, normalizes, or repairs. Nested duplicate legacy/postprocessor keys fail nonzero, never last-key-win.

Order: (1) add schemas/canonical route/strict audit/tests default-off while legacy parser remains; (2) audit both current configs and require nonzero `legacy_coaching_feedback_enabled`; (3) remove only that key and add valid disabled blocks to both configs, then audit active physique and disabled dual validity; (4) only then remove `coaching_feedback_enabled` from `PhysiqueCheckinConfig`, reject obsolete key, delete old finalized/generic path, rerun audit/wrappers; (5) promote by matrix. Rollback never restores obsolete path/key.

No touched code logs prompt/response/canonical/selected body, expansion/alias, raw ID, name, measurement, note, address, destination, exception text, headers, or response. It emits only static feature/surface/outcome codes; no telemetry holds selected text.

Files: new `gateway/platforms/korean_expression.py`, new strict audit script/tests/ADR-021; update `telegram.py`, `physique_checkin.py`, profile `models.py`/`store.py`/`wizard.py`, `nutrition_coaching.py`, both profile `customer_schedule.py`/`__init__.py`/`adaptive_nutrition.py`, both configs, `pyproject.toml`, `scripts/run_tests.sh`, and `듀얼코치_사용설명서.md`.

Hermetic coverage includes config matrix; hostile env/client isolation; opaque serialized wire; duplicate JSON/YAML; Unicode/scripts/NFC; lexical attacks and novel benign prose; all golden/frozen slots; daily/weekly/adaptive crash boundaries and late worker; prepared pin mismatch zero rerender/call; both adaptive producers/grammars; no log/audit/telemetry content; marker collection; and rollback drain/restart.

From `/home/cube/projects/richard/hermes-agent`, use wrappers only:

```text
scripts/run_tests.sh tests/gateway/test_korean_expression.py tests/gateway/test_telegram_physique_checkin.py tests/gateway/test_nutrition_coaching.py tests/gateway/test_adaptive_nutrition.py tests/gateway/test_telegram_group_gating.py
scripts/run_tests.sh /home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli/tests/test_customer_schedule.py /home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli/tests/test_adaptive_nutrition.py
scripts/run_tests.sh /home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/tests/test_customer_schedule.py /home/cube/.hermes/profiles/dualcoachtest/workspace/checkin_cli/tests/test_adaptive_nutrition.py
python scripts/audit_physique_config_cutover.py --config /home/cube/.hermes/profiles/physique-coach/config.yaml --config /home/cube/.hermes/profiles/dualcoachtest/config.yaml
scripts/run_tests.sh tests/gateway/test_korean_expression_live_shadow.py -- --collect-only -m live_korean_expression
scripts/run_tests.sh
```

Register `live_korean_expression` plus `integration`. `scripts/run_tests.sh` gains only `--live-korean-expression`: it forwards `RUN_KOREAN_EXPRESSION_LIVE=1` and `OPENAI_API_KEY` through its otherwise `env -i` child only when switch, flag, and key all exist; it never prints either. Controlled proof:

```text
RUN_KOREAN_EXPRESSION_LIVE=1 OPENAI_API_KEY="$OPENAI_API_KEY" scripts/run_tests.sh --live-korean-expression tests/gateway/test_korean_expression_live_shadow.py -- -m live_korean_expression
```

It runs four real coordinator paths × 12 distinct synthetic redacted manifests: exactly 48 successful one-request calls in a 24-hour candidate window, each within 600/160/760 budgets, `shadow_selected`, and canonical visible/durable daily/weekly/adaptive bytes only. Zero collection fails. Full Hermes wrapper suite plus both explicit profile suites precede promotion.

Rollback: first close the in-memory admission gate so new attempts synchronously canonical/no-call; stop scheduler/Telegram ingress; cancel/join workers and wait four-second claim deadline; canonical-finalize outstanding claims in their owning store; set affected config to valid disabled; strict-audit; then restart gateway/scheduler. Restarted disabled makes no client/request/audit attempt and only recovers already-authoritative surface bytes.

## Review closure and tradeoff

The nine critic changes are resolved by: exact config/profile matrix; sealed client/token bound; opaque wire aliases; surface-owned crash state machines; prepared-weekly current-pin loader; both adaptive producers; explicit daily replay; Unicode/duplicate YAML gates; wrapper/full-suite/live/rollback commands. The architect’s ambient SDK gap is closed by hostile-env rejection, explicit client, TLS/pinned transport, `trust_env=False`, redirects off, and regression tests. The critic’s cross-store journal objection is closed: daily authority is finalized check-in events, weekly authority is schedule delivery, adaptive authority is operator publication; metadata only mirrors terminal outcomes.

Finite phrase catalogs were rejected because they violate the approved free-prose intent; whole-body rewrite was rejected because it violates immutable semantic/layout anchors. Residual Korean paraphrase risk is honestly bounded by local anchors, deterministic checks, shadow evidence, default-off rollout, and canonical fallback—not claimed away. Consulted external repositories remain high-level attribution only; no material is copied.
