# v0.7.0 Implementation Design

| Field | Value |
|------|-----|
| Version | 0.7.0 |
| Status | FINAL |
| Created | 2026-05-17 |
| Predecessor | 0.6.7 implementation |
| Spec Source | kiro/specs/27-v070_prod/spec.md, design.md, tasks.md |
| AGENTS.md Version | 0.6.7 |

> This document provides detailed implementation guidance for the v0.7.0 spec.
> It translates the WHAT (requirements) and architecture-level HOW (design.md) into
> concrete implementation strategies, code-level changes, FFI contracts, and
> verification plans for each task.

---

## 1. Implementation Overview

v0.7.0 follows a phased execution model with strict dependency ordering:

```text
Phase 0 (A01-A07) ──► Phase 1 (B01-B06) ──► Phase 2 (C01-C06) ──► Phase 3 (D01-D05)
       │                                                          │
       └────────────────► Phase 4 (E01-E06) ◄─────────────────────┘
                                    │
                                    ▼
                          Phase 5 (F01-F05)
```

**Core principles for all implementation:**

1. **Pre-output Checklist first**: Validate every file write against AGENTS.md before writing.
2. **NGX_AGAIN is suspend-and-resume**: Never treat as success.
3. **Header-before-body**: Body data must not be sent before headers.
4. **FFI tail-append only**: New struct fields only at end; never reorder.
5. **Helper constructors required**: C side must use `*_init()` / `*_cleanup()`, never literal init.
6. **Delivery ≠ Decision**: Separate counters; delivery only on downstream NGX_OK.
7. **Rust-first**: New pure logic goes in Rust; C retains NGINX lifecycle only.
8. **Complete lifecycle**: Every new reason code/metric/config must cover: define → implement → test → docs → validator.
9. **No Python E2E**: Rule 37 — Rust-first E2E only.
10. **macOS bash 3.2 compat**: Shell scripts must avoid GNU-only flags.

---

## 2. Phase 0: P0 Runtime Correctness — Detailed Design

### TASK-A01: Fail-open Replay and Chunk Preservation

**Requirement**: REQ-0700-CORRECTNESS-001 | Rules: 1, 2, 38 | Risk Pack: runtime-streaming

#### Implementation Strategy

##### A01.1: Analyze replay buffer append-failure return semantics

- **File**: `components/nginx-module/src/ngx_http_markdown_buffer.c`
- **Action**: Audit all `ngx_http_markdown_replay_*` call sites. Document each path where replay buffer init or append can return `NGX_ERROR`. Map each to its `precommit_error` assignment.
- **Deliverable**: Comment block in `ngx_http_markdown_buffer.c` documenting all failure paths with `/* REPLAY-FAILURE-PATH: ... */` annotations.

##### A01.2: Pending chain persistence on NGX_AGAIN

- **File**: `components/nginx-module/src/ngx_http_markdown_filter_module.c`
- **Strategy**: In `ngx_http_markdown_body_filter`, when `ngx_output_chain()` returns `NGX_AGAIN`:
  1. Save `r->out` (the pending chain) to `ctx->pending_chain` before returning.
  2. On next invocation (resume), check `ctx->pending_chain != NULL`; if so, prepend it to the new output.
  3. **Critical**: Never overwrite `ctx->pending_chain` with newly produced output. Only append new chain links to the tail.
- **Data structure**:
  ```c
  typedef struct {
      ngx_chain_t  *pending_chain;    /* preserved across NGX_AGAIN */
      unsigned      pending_chain_active:1;  /* true when pending exists */
  } ngx_http_markdown_request_ctx_t;
  ```

##### A01.3: Last_buf protection

- **File**: `components/nginx-module/src/ngx_http_markdown_filter_module.c`
- **Strategy**: When `ctx->pending_chain_active` is true:
  1. Walk the pending chain to find any `buf->last_buf == 1`.
  2. If found and downstream has not consumed it yet, do NOT set `last_buf=1` on any new output buffer.
  3. Only set `last_buf=1` after downstream returns `NGX_OK` for the pending chain.

##### A01.4: Failopen_completed dedup finalization

- **File**: `components/nginx-module/src/ngx_http_markdown_error.c`
- **Strategy**: Add `failopen_completed` flag to request context:
  ```c
  typedef struct {
      unsigned      failopen_completed:1;  /* prevent duplicate finalize */
  } ngx_http_markdown_request_ctx_t;
  ```
  In every fail-open path before calling `ngx_http_finalize_request()`:
  ```c
  if (ctx->failopen_completed) {
      return;  /* already finalized */
  }
  ctx->failopen_completed = 1;
  ngx_http_finalize_request(r, status);
  ```

##### A01.5: Delivery vs Decision counter separation

- **Files**: `components/nginx-module/src/ngx_http_markdown_metrics_impl.h`, `ngx_http_markdown_prometheus_impl.h`
- **Strategy**:
  - **Decision counter**: Increment in every decision path (CONVERT, SKIP, FAIL-OPEN), immediately after the decision is made.
  - **Delivery counter**: Increment ONLY after `ngx_output_chain()` returns `NGX_OK`.
  - SHM layout additions:
    ```c
    ngx_atomic_t  markdown_decisions_total;  /* incremented at decision time */
    ngx_atomic_t  markdown_delivery_total;   /* incremented only on NGX_OK */
    ```
  - Prometheus output: expose both `markdown_decisions_total` and `markdown_delivery_total`.
  - Invariant: `delivery_total ≤ decisions_total` at all times.

##### A01.6: Max-size failure transparent passthrough

- **File**: `components/nginx-module/src/ngx_http_markdown_body_filter` logic
- **Strategy**: When `markdown_max_size` is exceeded mid-stream:
  1. Set `ctx->conversion_abandoned = 1`, increment decision counter.
  2. Do NOT discard subsequent upstream chunks; pass them through transparently.
  3. On last_buf, finalize as pass-through with original content-type.

##### A01.7: r->buffered semantic correctness

- **File**: `components/nginx-module/src/ngx_http_markdown_filter_module.c`
- **Strategy**: Clear `r->buffered` only when:
  - All pending chain has been consumed (downstream NGX_OK).
  - No more body data is expected.
  Never clear unconditionally on filter entry.

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| A01-T01 | NGX_AGAIN path: mock downstream returns NGX_AGAIN twice | pending_chain preserved, data complete on resume |
| A01-T02 | Replay buffer init fails (pool exhausted mock) | precommit_error=1, log contains "replay buffer" |
| A01-T03 | Replay buffer append fails mid-stream | precommit_error=1, fail-open pass-through |
| A01-T04 | failopen_completed prevents double finalize | finalize called exactly once |
| A01-T05 | delivery counter only on NGX_OK | fail-open: decisions=1, delivery=0 |
| A01-T06 | Middle chunk max-size failure, 3 chunks | chunk 2 fails, chunks 1 and 3 delivered as-is |
| A01-T07 | r->buffered cleared only on completion | NGX_AGAIN during pending: r->buffered stays set |

#### Coverage Checkpoint

- `make coverage-c` — verify body_filter and buffer files ≥ 90% critical path coverage
- `make test-nginx-unit-streaming && make verify-chunked-native-e2e-smoke`

---

### TASK-A02: Safe Conversion Output Ordering

**Requirement**: REQ-0700-CORRECTNESS-002 | Risk Pack: nginx-protocol-safety

#### Implementation Strategy

##### A02.1: Alloc-before-send refactoring

- **File**: `components/nginx-module/src/ngx_http_markdown_conversion_impl.h`
- **Strategy**: Restructure the conversion success path:
  ```
  1. Allocate body buffer (ngx_palloc) → if fail, goto fail_open
  2. Allocate chain link (ngx_alloc_chain_link) → if fail, goto fail_open
  3. Copy FFI markdown output to pool buffer
  4. Set buffer properties (pos, last, last_buf, memory, flush)
  5. Prepare all header mutations (Content-Type, ETag, Vary, etc.)
  6. Call ngx_http_send_header() — exactly once
  7. Send body chain via ngx_output_chain()
  ```
  **Key invariant**: Steps 1-5 must all succeed before step 6 executes.

##### A02.2: Rust FFI output → pool copy

- **File**: `components/nginx-module/src/ngx_http_markdown_conversion_impl.h`
- **Strategy**: After FFI returns markdown buffer:
  ```c
  /* Allocate pool buffer BEFORE any header operations */
  body_buf = ngx_pnalloc(r->pool, result.output_len);
  if (body_buf == NULL) { goto fail_open; }
  ngx_memcpy(body_buf, result.output, result.output_len);
  /* NOW safe to proceed with headers */
  ```

##### A02.3: Header mutation only after output resources ready

- Same restructured flow as A02.1. Header mutations happen between step 5 and 6.

##### A02.4: ngx_http_send_header called once

- **File**: `components/nginx-module/src/ngx_http_markdown_headers_impl.h`
- **Strategy**: Add `headers_sent` flag to request context:
  ```c
  if (!ctx->headers_sent) {
      rc = ngx_http_send_header(r);
      ctx->headers_sent = 1;
  }
  ```
  Body filter entry checks this flag and skips send_header if already sent.

##### A02.5: Allocation failure documentation

- Document the fault-injection approach: set `NGX_MARKDOWN_TEST_ALLOC_FAIL=1` env var to trigger `ngx_palloc` failure injection in test builds.

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| A02-T01 | Verify header sent before body | Assert send_header call precedes body chain send |
| A02-T02 | Header send idempotency | Multiple filter entries → send_header called once |
| A02-T03 | Alloc failure in body buffer | Fail-open pass-through, no partial body sent |

#### Coverage Checkpoint

- `make test-nginx-unit && make test-nginx-integration && make coverage-c`

---

### TASK-A03: Rust Bounded Decompression

**Requirement**: REQ-0700-CORRECTNESS-003 | Rules: 3, 32 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### A03.1-A03.2: Replace input_size*10 with explicit budget

- **File**: `components/rust-converter/src/ffi/options.rs` (MarkdownOptions), new file `components/rust-converter/src/decompress/bounded.rs`
- **Strategy**:
  ```rust
  pub struct BoundedDecompressor {
      budget: usize,         // explicit upper bound in bytes
      output: Vec<u8>,       // growing output buffer
  }

  impl BoundedDecompressor {
      pub fn new(budget: usize) -> Self { ... }
      pub fn decompress(&mut self, input: &[u8]) -> Result<(), DecompressionError> {
          // Use flate2/brotli with per-chunk budget check
          // After each chunk: if self.output.len() > self.budget → BudgetExceeded
      }
  }
  ```
- Default budget: `input_len * 20` if `decompression_budget == 0` in config.
- Config override: `markdown_decompression_budget <bytes>;`

##### A03.3: DecompressionError enum

- **File**: `components/rust-converter/src/decompress/error.rs` (new)
  ```rust
  #[repr(C)]
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  pub enum DecompressionError {
      BudgetExceeded    = 1,
      FormatError       = 2,
      TruncatedInput    = 3,
      IoError           = 4,
  }
  ```

##### A03.4: Auxiliary buffer cleanup on all exit paths

- Every `match` arm and `?` propagation in `BoundedDecompressor::decompress` must drop auxiliary buffers.
- Rust ownership model handles this, but verify with a test that checks `drop` was called (via `Drop` impl tracking).

##### A03.5-A03.6: FFI struct changes

- **MarkdownOptions** (tail-append):
  ```rust
  #[repr(C)]
  pub struct MarkdownOptions {
      // ... existing fields ...
      pub decompression_budget: u64,   // NEW: 0 = default (input*20)
  }
  ```
- **FFIDecompResult** (new struct):
  ```rust
  #[repr(C)]
  pub struct FFIDecompResult {
      pub output: *mut u8,
      pub output_len: usize,
      pub error: DecompressionError,    // 0 = success
      pub decompressed_size: usize,     // actual output size
  }
  ```

##### A03.7: New config directive

- `markdown_decompression_budget <size>`; default: 0 (use input*20); context: http, server, location

##### A03.12: Per-category metrics

- `decompression_budget_exceeded_total`
- `decompression_format_error_total`
- `decompression_truncated_input_total`
- `decompression_io_error_total`
- **NOT** aggregated into `conversion_error_total`

#### FFI Change Registry Entry

| Struct | Change | ABI Impact |
|--------|--------|-----------|
| MarkdownOptions | +decompression_budget: u64 (tail) | Non-breaking (tail-append) |
| FFIDecompResult | New struct | New, no existing consumers |

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| A03-T01 | Compress ratio 15x, budget 20x | Decompress succeeds |
| A03-T02 | Compress ratio 25x, budget 20x | BudgetExceeded returned |
| A03-T03 | Malformed gzip input | FormatError returned |
| A03-T04 | Truncated gzip stream | TruncatedInput returned |
| A03-T05 | Fuzz: random input to decompress_bounded | No panic, returns error |
| A03-T06 | C: FFI bounded decompress pass-through | Budget exceeded → fail-open |

#### Coverage Checkpoint

- `make test-rust && make test-rust-fuzz-smoke && make test-nginx-unit && make coverage-rust && make coverage-c`

---

### TASK-A04: Decompression Error Category Preservation

**Requirement**: REQ-0700-CORRECTNESS-004 | Rule: 15 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### A04.1: Complete FFI error code mapping

- **File**: `components/rust-converter/src/decompress/error.rs`, `components/nginx-module/src/markdown_converter.h`
- Mapping table:

| Rust DecompressionError | C FFI Constant | Value |
|------------------------|---------------|-------|
| BudgetExceeded | FFI_DECOMPRESSION_BUDGET_EXCEEDED | 1 |
| FormatError | FFI_DECOMPRESSION_FORMAT_ERROR | 2 |
| TruncatedInput | FFI_DECOMPRESSION_TRUNCATED_INPUT | 3 |
| IoError | FFI_DECOMPRESSION_IO_ERROR | 4 |
| (success) | FFI_DECOMPRESSION_OK | 0 |

##### A04.2: Per-category Prometheus counters

- C side must increment the specific counter matching the error category, NOT `conversion_error_total`.

##### A04.3: Log with full category

- `ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, "markdown decompression failed: category=%s", decomp_error_name(result.error))`

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| A04-T01 | Each error variant → correct FFI code | Enum variant maps to expected C constant |
| A04-T02 | Resource limit failure increments budget counter | Not format_error counter |
| A04-T03 | Enum count == C define count | No unmapped variants |

#### Coverage Checkpoint

- `make test-rust && make test-nginx-unit && make coverage-rust && make coverage-c`

---

### TASK-A05: Rust Accept Negotiation

**Requirement**: REQ-0700-CORRECTNESS-005 | Risk Pack: nginx-protocol-safety

#### Implementation Strategy

##### A05.1-A05.7: Rust negotiate_accept implementation

- **New file**: `components/rust-converter/src/negotiator/accept.rs`
- **New file**: `components/rust-converter/src/negotiator/mod.rs`

```rust
/// Available MIME variants for negotiation
const AVAILABLE_VARIANTS: &[&str] = &["text/markdown", "text/html"];

#[repr(C)]
pub enum AcceptDecision {
    Convert     = 0,  // text/markdown wins
    Skip        = 1,  // text/html wins or no markdown
    Reject      = 2,  // text/markdown;q=0 explicit reject
    NoAccept    = 3,  // no Accept header
}

#[repr(C)]
pub struct FFIAcceptResult {
    pub decision: AcceptDecision,
    pub markdown_q: f32,      // q-value for text/markdown
    pub html_q: f32,          // q-value for text/html
    pub on_wildcard: u8,      // 0=skip, 1=convert (from config)
}

#[no_mangle]
pub extern "C" fn markdown_negotiate_accept(
    accept_header: *const c_char,
    accept_len: usize,
    on_wildcard: u8,
) -> FFIAcceptResult {
    // 1. If NULL/empty → NoAccept
    // 2. Parse Accept header → Vec<(mime, q)>
    // 3. Filter to AVAILABLE_VARIANTS
    // 4. For each: if q not specified → 1.0
    // 5. If text/markdown;q=0 → Reject
    // 6. If */* present → use on_wildcard config
    // 7. Compare markdown_q vs html_q → Convert or Skip
}
```

**Key algorithm details:**
- Parse Accept: split by `,`, trim, split each by `;`, extract `q=` parameter
- Case-insensitive MIME type matching
- Default q-value = 1.0 (RFC 7231 §5.3.1)
- Malformed q-value → treat as 1.0 with warning
- Ignore non-available MIME types regardless of q-value
- `*/*` behavior controlled by `markdown_on_wildcard` config

##### A05.8: FFI struct

- `FFIAcceptResult` — see above (tail-append safe, new struct)

##### A05.9: C integration

- **File**: `components/nginx-module/src/ngx_http_markdown_accept.c`
- In header_filter, replace existing C-side Accept parsing with FFI call:
  ```c
  FFIAcceptResult accept_result = markdown_negotiate_accept(
      accept_header.data, accept_header.len, ctx->on_wildcard);
  ```

#### Table-Driven Test Cases

| Accept Header | Expected | Reason |
|--------------|----------|--------|
| `text/markdown` | Convert, q=1.0 | default q |
| `text/html` | Skip | only html available |
| `text/markdown;q=0.9, text/html;q=0.8` | Convert | markdown q higher |
| `text/markdown;q=0.8, text/html;q=0.9` | Skip | html q higher |
| `application/json, text/markdown;q=0.9` | Convert | json ignored |
| `text/markdown;q=0` | Reject | explicit reject |
| `*/*` | Convert/Skip | depends on on_wildcard |
| (missing) | NoAccept | no Accept header |
| `text/markdown;q=0.5, text/html` | Skip | html q=1.0 > 0.5 |
| `TEXT/MARKDOWN` | Convert | case-insensitive |
| `text/markdown; q =0.9` | Convert, q=0.9 | space-tolerant parsing |

#### Coverage Checkpoint

- `make test-rust && make test-nginx-unit && make test-e2e-rust && make coverage-rust`

---

### TASK-A06: Parse Timeout and Parser Budget

**Requirement**: REQ-0700-CORRECTNESS-006 | Rules: 3, 7, 17 | Risk Pack: runtime-streaming

#### Implementation Strategy

##### A06.1: Parse timeout

- **New file**: `components/nginx-module/src/ngx_http_markdown_parse_budget.c`
- **New file**: `components/nginx-module/src/ngx_http_markdown_parse_budget.h`
- Strategy: Use `ngx_event_add_timer` on the request's `ngx_event_t` before FFI call.
  - If timer fires before FFI returns, set `ctx->parse_timed_out = 1`.
  - After FFI returns, check `ctx->parse_timed_out` and proceed to fail-open with `PARSE_TIMEOUT`.
  - Default: 30 seconds; configurable via `markdown_parse_timeout <seconds>;`

##### A06.2: Parser memory budget

- Pass `parser_memory_budget` to Rust FFI as part of `MarkdownOptions` (tail-append).
- Rust side: track allocated bytes during parsing. If exceeds budget → return `PARSE_BUDGET_EXCEEDED`.
- Default: 64MB; configurable via `markdown_parser_budget <bytes>;`

##### A06.3: Parser interrupt feasibility

- Document: Rust HTML parser (html5ever/scraper) does not support mid-parse interruption.
- Alternative: Use depth limit + node count limit as proxy budget.
- If true timeout is needed, consider spawning conversion in a separate task with a watchdog (future work).

##### A06.4-A06.6: Error codes, config, metrics

- New reason codes: `PARSE_TIMEOUT`, `PARSE_BUDGET_EXCEEDED`
- New metrics: `parse_timeouts_total`, `parse_budget_exceeded_total`
- New config: `markdown_parse_timeout`, `markdown_parser_budget`

#### FFI Change

- **MarkdownOptions** (tail-append):
  ```rust
  pub parse_timeout_ms: u64,         // NEW: 0 = no timeout, else milliseconds
  pub parser_memory_budget: u64,     // NEW: 0 = no limit, else bytes
  ```

#### Coverage Checkpoint

- `make test-rust-streaming && make test-nginx-unit-streaming && make coverage-c && make coverage-rust`

---

### TASK-A07: FFI ABI Layout and Header Drift Check

**Requirement**: REQ-0700-CORRECTNESS-007 | Rule: 15 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### A07.1: CI cbindgen header diff

- **File**: `.github/workflows/ci.yml` (add step)
  ```yaml
  - name: Check FFI header drift
    run: |
      cargo build --manifest-path components/rust-converter/Cargo.toml
      diff components/rust-converter/include/markdown_converter.h \
           components/nginx-module/src/markdown_converter.h
      # Non-zero exit → FAIL
  ```

##### A07.2: C static_assert

- **File**: `components/nginx-module/src/markdown_converter.h` (add at bottom)
  ```c
  /* ABI layout guards — must match Rust side */
  ngx_static_assert(sizeof(MarkdownOptions) == MARKDOWN_OPTIONS_EXPECTED_SIZE);
  ngx_static_assert(offsetof(MarkdownOptions, decompression_budget) == MARKDOWN_OPTIONS_DECOMPRESSION_BUDGET_OFFSET);
  /* ... per-field ... */
  ```

##### A07.3: Rust layout tests

- **File**: `components/rust-converter/tests/layout_tests.rs` (new)
  ```rust
  #[test]
  fn markdown_options_layout() {
      assert_eq!(std::mem::size_of::<MarkdownOptions>(), EXPECTED_SIZE);
      assert_eq!(std::mem::offset_of!(MarkdownOptions, decompression_budget), EXPECTED_OFFSET);
      // ... per-field ...
  }
  ```

##### A07.4: Helper constructors

- **File**: `components/nginx-module/src/ngx_http_markdown_ffi_helpers.c` (new or extend)
  ```c
  void markdown_options_init(MarkdownOptions *opts) {
      ngx_memzero(opts, sizeof(MarkdownOptions));
      opts->decompression_budget = 0;   /* default: input*20 */
      opts->parse_timeout_ms = 30000;   /* default: 30s */
      opts->parser_memory_budget = 64 * 1024 * 1024; /* default: 64MB */
      /* ... all fields with defaults ... */
  }

  void markdown_options_cleanup(MarkdownOptions *opts) {
      /* Release any Rust-owned fields if needed */
      /* Currently no Rust-owned fields in options, but future-proof */
  }
  ```

##### A07.6: Copied-header sync check

- **Makefile target**: `check-headers`
  ```makefile
  check-headers:
      diff components/rust-converter/include/markdown_converter.h \
           components/nginx-module/src/markdown_converter.h
  ```

#### Coverage Checkpoint

- `make build && make check-headers && make test-rust && make test-nginx-unit && make harness-check-full`

---

## 3. Phase 1: Rust-first Architecture — Detailed Design

### TASK-B01: C/Rust Boundary Strategy and Migration Contract

**Requirement**: REQ-0700-RUST-001 | Rule: 15 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B01.1: FFI_MIGRATION_CONTRACT.md

- **New file**: `docs/architecture/FFI_MIGRATION_CONTRACT.md`
- Content: Table of all FFI functions with columns: Function, Ownership (C/Rust/Shared), Migration Status (Original/Migrating/Migrated/Never), Compatibility, Added-in-version.
- Populate with existing + 0.7.0 new functions.

##### B01.2: C code audit for Rust-scope logic

- Scan all C files for: Accept parsing, ETag matching, URL validation, header plan construction, decision logic.
- Mark each with `/* RUST-MIGRATION-CANDIDATE: ... */` comment.

##### B01.3: Migration priority list

- Use the priority table from `0.7.0-rust-first-migration-plan.md` §3.

##### B01.4: ADR update

- Create ADR-0010: Rust-first C/Rust Boundary Evolution for v0.7.0.

---

### TASK-B02: Rust Conditional Request/ETag

**Requirement**: REQ-0700-RUST-002 | Rules: 15, 27 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B02.1: If-None-Match parsing

- **New files**: `components/rust-converter/src/conditional/etag.rs`, `conditional/mod.rs`
  ```rust
  #[repr(C)]
  pub struct FFIConditionalResult {
      pub is_not_modified: u8,      // 1 = send 304
      pub new_etag: *mut c_char,    // W/"<hash>" (Rust-owned, release via FFI)
      pub new_etag_len: usize,
      pub reason_code: u32,         // SKIPPED_CONDITIONAL or CONVERTED
  }

  /// Parse If-None-Match header, compare with upstream ETag
  /// Supports both strong and weak comparison (RFC 7232 §2.3.2)
  #[no_mangle]
  pub extern "C" fn markdown_check_conditional(
      if_none_match: *const c_char, if_none_match_len: usize,
      if_modified_since: *const c_char, if_modified_since_len: usize,
      upstream_etag: *const c_char, upstream_etag_len: usize,
      last_modified: *const c_char, last_modified_len: usize,
  ) -> FFIConditionalResult { ... }
  ```

##### B02.2: If-Modified-Since parsing

- Parse HTTP-date format (RFC 7231 §7.1.1.1), compare with `Last-Modified`.

##### B02.3: Weak ETag generation

- After conversion, compute SHA-256 of markdown output → `W/"<hex_prefix_16>"`
- Return via `FFIConditionalResult.new_etag`.

##### B02.5: C integration

- Replace C-side `ngx_http_markdown_conditional.c` logic with FFI call.
- C side still calls `ngx_http_finalize_request(r, NGX_HTTP_NOT_MODIFIED)` for 304.

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| B02-T01 | If-None-Match matches upstream ETag | is_not_modified=1, reason=SKIPPED_CONDITIONAL |
| B02-T02 | Weak ETag W/"abc" matches W/"abc" | 304 |
| B02-T03 | If-Modified-Since >= Last-Modified | 304 |
| B02-T04 | Conversion generates valid W/" hash " | Subsequent If-None-Match matches |

---

### TASK-B03: Rust URL and Forwarded Host Validation

**Requirement**: REQ-0700-RUST-003 | Rule: 27 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B03.1: Control character rejection

- **New files**: `components/rust-converter/src/validation/url.rs`, `validation/mod.rs`
  ```rust
  /// Reject URLs containing C0 control chars (U+0000-U+001F except HT/LF/CR)
  pub fn validate_url(url: &str) -> Result<(), UrlValidationError> { ... }
  ```

##### B03.2: X-Forwarded-* parsing

- **New file**: `components/rust-converter/src/validation/forwarded.rs`
  ```rust
  pub fn build_base_url(forwarded_host: &str, forwarded_proto: &str,
                        default_host: &str, default_proto: &str) -> String { ... }
  ```

##### B03.3: Link emission Markdown escape

- Escape `]` → `%5D`, `[` → `%5B`, etc. in link labels and destinations.

##### B03.5: FFI exposure

  ```rust
  #[no_mangle]
  pub extern "C" fn markdown_validate_url(
      url: *const c_char, url_len: usize,
      forwarded_host: *const c_char, forwarded_host_len: usize,
      forwarded_proto: *const c_char, forwarded_proto_len: usize,
  ) -> FFIUrlResult { ... }
  ```

#### Regression Tests

| Test ID | What | Expected |
|---------|------|----------|
| B03-T01 | URL with \\x01 | URL_INVALID_CONTROL_CHAR, link not emitted |
| B03-T02 | X-Forwarded-Host + Proto | base URL = "https://api.example.com" |
| B03-T03 | Link destination with ] | Escaped to %5D |
| B03-T04 | Fuzz: random URL bytes | No panic, returns error |

---

### TASK-B04: Rust Response Header Plan Prototype

**Requirement**: REQ-0700-RUST-004 | Rules: 15, 29 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B04.1: HeaderPlan struct

- **New file**: `components/rust-converter/src/header/plan.rs`
  ```rust
  #[repr(C)]
  pub enum HeaderAction {
      Set    = 0,
      Delete = 1,
      Modify = 2,
  }

  #[repr(C)]
  pub struct FFIHeaderPlan {
      pub entries: *mut FFIHeaderEntry,
      pub count: usize,
  }

  #[repr(C)]
  pub struct FFIHeaderEntry {
      pub action: HeaderAction,
      pub name: *const c_char,
      pub name_len: usize,
      pub value: *const c_char,
      pub value_len: usize,
  }
  ```

##### B04.2: Plan construction

- Build plan for conversion: Set Content-Type, Delete Content-Length, Set ETag, Set Vary, etc.
- Build plan for pass-through: (empty plan or minimal).

##### B04.4: C atomic application

  ```c
  /* Apply all or rollback all */
  ngx_int_t markdown_apply_header_plan(ngx_http_request_t *r, FFIHeaderPlan *plan) {
      /* Phase 1: Validate all operations can succeed */
      /* Phase 2: Apply all operations */
      /* If any fails in phase 2 → rollback (undo what was done) */
      /* Note: NGINX header list doesn't support true rollback, */
      /* so we validate-first, apply-once */
  }
  ```

---

### TASK-B05: Rust Decision Engine Phase 1

**Requirement**: REQ-0700-RUST-005 | Rules: 7, 23 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B05.1-B05.2: DecisionContext and make_decision

- **New files**: `components/rust-converter/src/decision/engine.rs`, `decision/reason_code.rs`, `decision/mod.rs`
  ```rust
  #[repr(C)]
  pub struct DecisionContext {
      pub accept_result: FFIAcceptResult,
      pub conditional_result: FFIConditionalResult,
      pub is_eligible: u8,
      pub has_accept_header: u8,
      pub error_policy: u8,       // 0=pass, 1=reject
      pub decompression_error: u8, // DecompressionError value
  }

  #[repr(C)]
  pub struct FFIDecisionResult {
      pub action: DecisionAction,  // Convert, Skip, FailOpen, FailClosed
      pub reason_code: ReasonCode,
  }

  /// Pure function — same input always produces same output
  #[no_mangle]
  pub extern "C" fn markdown_make_decision(ctx: &DecisionContext) -> FFIDecisionResult { ... }
  ```

##### B05.3: Decision path coverage

- Every branch in `make_decision` must produce a `ReasonCode`:
  - CONVERTED, SKIPPED_ACCEPT, SKIPPED_NO_ACCEPT, SKIPPED_CONDITIONAL,
  - FAILED_DECOMPRESSION_BUDGET_EXCEEDED, FAILED_DECOMPRESSION_FORMAT_ERROR,
  - FAILED_DECOMPRESSION_TRUNCATED_INPUT, FAILED_DECOMPRESSION_IO_ERROR,
  - PARSE_TIMEOUT, PARSE_BUDGET_EXCEEDED, REPLAY_BUFFER_ERROR

##### B05.5: FFI exposure

- `markdown_make_decision(context) → FFIDecisionResult`
- `markdown_get_reason_code_string(code) → *const c_char` (static string, no free needed)

---

### TASK-B06: Reason-code and Metrics Normalization

**Requirement**: REQ-0700-RUST-006 | Rules: 7, 8, 8b, 8c, 23 | Risk Pack: ffi-boundary

#### Implementation Strategy

##### B06.1: Rust enum single source

- **File**: `components/rust-converter/src/decision/reason_code.rs`
  ```rust
  #[repr(C)]
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  pub enum ReasonCode {
      Converted                     = 0,
      SkippedAccept                 = 1,
      SkippedNoAccept               = 2,
      SkippedConditional            = 3,
      FailedDecompressionBudget     = 4,
      FailedDecompressionFormat     = 5,
      FailedDecompressionTruncated  = 6,
      FailedDecompressionIo         = 7,
      ParseTimeout                  = 8,
      ParseBudgetExceeded           = 9,
      ReplayBufferError             = 10,
      FfiCallError                  = 11,
  }
  ```

##### B06.2: C side via FFI

- Remove all `#define MARKDOWN_REASON_*` constants from C code.
- C side calls `markdown_get_reason_code_string(code)` for logging and metrics.

##### B06.3: Prometheus name consistency

- Every metric name in Rust must match the Prometheus output format exactly.
- Validator checks: `assert_eq!(rust_metric_name, prometheus_output_name)`.

##### B06.4: Delivery vs Decision as separate Prometheus series

- `markdown_decisions_total{reason="CONVERTED"}` — incremented at decision time
- `markdown_delivery_total` — incremented only on downstream NGX_OK
- These are separate Prometheus time series.

---

## 4. Phase 2: Package Distribution — Detailed Design

### TASK-C01: Package Distribution Strategy

- **New file**: `docs/guides/PACKAGE_DISTRIBUTION.md`
- Content: channels (GitHub Releases + self-hosted APT/YUM), non-official disclaimer, ABI sensitivity, default-safe installation.

### TASK-C02: Package Matrix and Metadata Model

- **New file**: `packaging/matrix.yaml`
  ```yaml
  matrix:
    os:
      - { name: ubuntu, version: "22.04", codename: jammy, pkg: deb, libc: glibc }
      - { name: ubuntu, version: "24.04", codename: noble, pkg: deb, libc: glibc }
      - { name: debian, version: "12", codename: bookworm, pkg: deb, libc: glibc }
      - { name: almalinux, version: "9", pkg: rpm, libc: glibc }
      - { name: amazon-linux, version: "2023", pkg: rpm, libc: glibc }
    arch: [amd64, arm64]
    nginx_channel: [stable, mainline]
  ```
- Package metadata schema:
  ```yaml
  package:
    name: nginx-markdown-module
    version: "${VERSION}"
    architecture: "${ARCH}"
    depends: "nginx-abi-${NGINX_ABI_SIGNATURE}"
    description: "NGINX module converting HTML to Markdown for AI agents"
    license: "BSD-2-Clause"
    maintainer: "nginx-markdown-for-agents team"
  ```

### TASK-C03: DEB Package Build Pipeline

#### DEB directory structure

```
packaging/debian/
├── control          # Package metadata + dependencies
├── postinst         # load_module snippet (conservative: commented out)
├── postrm           # Cleanup on removal
├── conffiles        # Config files not overwritten on upgrade
├── rules            # dpkg-buildflags integration
└── changelog.Debian # Package changelog
```

#### Control file template

```
Source: nginx-markdown-module
Section: httpd
Priority: optional
Maintainer: nginx-markdown-for-agents team <team@example.com>
Build-Depends: debhelper (>= 10), libnginx-mod-http-headers-more-filter
Standards-Version: 4.5.1

Package: nginx-markdown-module
Architecture: amd64 arm64
Depends: nginx-abi-1.26.0
Description: NGINX HTML-to-Markdown conversion module
 Module converting upstream HTML responses to Markdown format
 for AI agent consumption, with streaming support and content negotiation.
```

#### postinst script

```bash
#!/bin/sh
# postinst: conservative enable — module .so installed but not loaded
# User must uncomment in /etc/nginx/modules-enabled/mod-markdown.conf
set -e
case "$1" in
    configure)
        # Create load_module snippet (commented out by default)
        CONF="/etc/nginx/modules-available/mod-markdown.conf"
        if [ ! -f "$CONF" ]; then
            echo "# load_module modules/ngx_http_markdown_filter_module.so;" > "$CONF"
        fi
        ;;
esac
```

#### CI workflow structure

- **File**: `.github/workflows/release-deb.yml`
  ```yaml
  name: Build DEB packages
  on: [push]  # tags only for release
  jobs:
    build-deb:
      strategy:
        matrix:
          os: [ubuntu-22.04, ubuntu-24.04, debian-12]
          arch: [amd64, arm64]
          nginx: [stable, mainline]
      steps:
        - uses: actions/checkout@v4
        - name: Install build dependencies
          run: ...
        - name: Build NGINX + module
          run: make build-module
        - name: Build DEB package
          run: dpkg-deb --build packaging/debian dist/
        - name: Verify package
          run: |
            dpkg-deb --info dist/*.deb
            dpkg-deb --contents dist/*.deb
        - name: Sign package
          run: dpkg-sig -k $GPG_KEY_ID --sign dist/*.deb
        - name: Upload artifact
          uses: actions/upload-artifact@v4
  ```

### TASK-C04: RPM Package Build Pipeline

#### RPM SPEC template

```spec
Name:           nginx-markdown-module
Version:        %{VERSION}
Release:        1%{?dist}
Summary:        NGINX HTML-to-Markdown conversion module
License:        BSD-2-Clause
URL:            https://github.com/user/nginx-markdown-for-agents
Source0:        %{name}-%{version}.tar.gz

BuildRequires:  gcc, make, openssl-devel, pcre2-devel, zlib-devel
Requires:       nginx-abi-%{nginx_abi_version}

%description
NGINX module converting upstream HTML responses to Markdown format
for AI agent consumption.

%post
# Create load_module snippet (commented out by default)
CONF=/etc/nginx/modules-available/mod-markdown.conf
if [ ! -f "$CONF" ]; then
    echo "# load_module modules/ngx_http_markdown_filter_module.so;" > "$CONF"
fi

%postun
# Cleanup on removal
rm -f /etc/nginx/modules-available/mod-markdown.conf

%files
%defattr(-,root,root,-)
/usr/lib/nginx/modules/ngx_http_markdown_filter_module.so
%config(noreplace) /etc/nginx/modules-available/mod-markdown.conf

%changelog
* Fri May 17 2026 team <team@example.com> - 0.7.0-1
- v0.7.0: P0 runtime correctness, Rust-first architecture, package distribution
```

### TASK-C05: APT/RPM Repository and GPG Signing

#### GPG signing flow

1. CI has access to GPG private key via encrypted secrets.
2. DEB: `dpkg-sig --sign builder -k $KEY_ID dist/*.deb`
3. APT repo: `apt-ftparchive packages . > Packages && apt-ftparchive release . > Release && gpg --clearsign -u $KEY_ID -o InRelease Release && gpg -abs -u $KEY_ID -o Release.gpg Release`
4. RPM: `rpm --addsign dist/*.rpm`
5. YUM repo: `createrepo_c dist/ && gpg --detach-sign --armor dist/repodata/repomd.xml`

#### APT repository structure

```
repo/apt/
├── dists/
│   └── bookworm/
│       ├── main/
│       │   ├── binary-amd64/
│       │   │   ├── Packages
│       │   │   └── Packages.gz
│       │   └── binary-arm64/
│       │       ├── Packages
│       │       └── Packages.gz
│       ├── InRelease
│       ├── Release
│       └── Release.gpg
└── pool/
    └── main/
        └── n/
            └── nginx-markdown-module/
                └── nginx-markdown-module_0.7.0-1_amd64.deb
```

### TASK-C06: Installation/Upgrade/Rollback/Troubleshooting Docs

- **New file**: `docs/guides/PACKAGE_INSTALLATION.md`
- Sections per OS: install → verify → upgrade → rollback → troubleshoot
- Verify command: `nginx -V 2>&1 | grep -q ngx_http_markdown_module && echo OK`
- Rollback: `dpkg -i nginx-markdown-module_0.6.7-1_amd64.deb && nginx -s reload`

---

## 5. Phase 3: Kubernetes/Ingress — Detailed Design

### TASK-D01: K8s/Ingress Strategy

- **New file**: `docs/guides/KUBERNETES_DEPLOYMENT.md`
- Clear "reference example, not production-ready" disclaimer.
- List verified controllers: OSS NGINX Ingress Controller.
- Unverified: F5 NGINX Ingress Controller (pending TASK-D04).

### TASK-D02: Custom Image Build

#### Dockerfile template

- **New file**: `examples/kubernetes/Dockerfile.ingress`
  ```dockerfile
  # Parameterized NGINX Ingress Controller base image
  ARG NGINX_INGRESS_IMAGE=registry.k8s.io/ingress-nginx/controller:v1.11.2
  FROM $NGINX_INGRESS_IMAGE AS base

  # Build stage: compile module against exact NGINX version
  FROM golang:1.22 AS module-builder
  ARG NGINX_VERSION=1.27.1
  # ... compile module .so ...

  # Final image: inject module
  FROM base
  COPY --from=module-builder /build/ngx_http_markdown_filter_module.so /usr/lib/nginx/modules/
  RUN echo "load_module modules/ngx_http_markdown_filter_module.so;" \
      > /etc/nginx/modules/mod-markdown.conf
  ```

### TASK-D03: K8s Manifests and Helm Chart

#### Manifest structure

```
examples/kubernetes/manifest/
├── deployment.yaml      # NGINX deployment with custom image
├── configmap.yaml       # Module configuration (markdown_filter, dynconf)
├── service.yaml         # ClusterIP service
├── ingress.yaml         # Ingress resource with annotation
└── namespace.yaml       # Optional namespace
```

#### Helm chart extension

- Extend `charts/nginx-markdown/Chart.yaml` with v0.7.0 values:
  - `markdown.enabled`, `markdown.decompressionBudget`, `markdown.parseTimeout`
  - `markdown.dynconf.enabled`, `markdown.diagnostics.enabled`

### TASK-D04: F5 NGINX Ingress Controller Feasibility

- **New file**: `docs/guides/F5_INGRESS_FEASIBILITY.md`
- Assess: Does F5 NGINX Ingress Controller support custom dynamic module injection?
- Document: injection method (if possible), known limitations, alternative approaches (sidecar, custom build).

### TASK-D05: K8s Smoke and E2E

- **New file**: `examples/kubernetes/tests/smoke.sh` (bash 3.2 compatible)
  ```bash
  #!/bin/bash
  set -euo pipefail
  # 1. Deploy
  kubectl apply -f examples/kubernetes/manifest/
  kubectl wait --for=condition=ready pod -l app=nginx-markdown --timeout=60s
  # 2. Test conversion
  RESULT=$(kubectl exec deployment/nginx-markdown -- curl -s -H "Accept: text/markdown" http://localhost:8080/test)
  echo "$RESULT" | grep -q "^#" && echo "PASS: conversion" || echo "FAIL: conversion"
  # 3. Test Accept negotiation
  # 4. Test metrics endpoint
  ```
- **New file**: `examples/kubernetes/tests/e2e.sh` — deploy, upgrade, rollback, scale scenarios.

---

## 6. Phase 4: Operability + Release Gates — Detailed Design

### TASK-E01: Runtime Explainability and Diagnostics

**Requirement**: REQ-0700-OPERABILITY-001 | Risk Pack: dynamic-config-hot-reload

#### Implementation Strategy

##### E01.1: Diagnostics endpoint

- **New files**: `components/nginx-module/src/ngx_http_markdown_diagnostics.c`, `ngx_http_markdown_diagnostics.h`
- Endpoint: `/nginx-markdown/diagnostics`
- Access: `markdown_diagnostics on;` + `markdown_diagnostics_allow 127.0.0.1;`
- Default: OFF. Must be explicitly enabled.

##### E01.2: Structured decision log

- Every request logs: `accept_result, conditional_result, conversion_status, reason_code, duration_ms`
- Format: `ngx_log_error(NGX_LOG_INFO, ..., "markdown decision: accept=%d conditional=%d convert=%d reason=%s duration=%dms", ...)`

##### E01.3: Diagnostics output

  ```json
  {
    "config_snapshot": { "markdown_filter": "on", "decompression_budget": "10485760", ... },
    "recent_decisions": [ { "ts": "...", "reason": "CONVERTED", "duration_ms": 12 } ],
    "metrics_snapshot": { "conversions_total": 1234, "delivery_total": 1230 },
    "dynconf_state": { "active_mtime": "...", "config_version": 42, "last_known_good_mtime": "..." }
  }
  ```

##### E01.4: Config directive

- `markdown_diagnostics on | off;` — default off
- `markdown_diagnostics_allow <CIDR>;` — access control

---

### TASK-E02: Dynconf Dry-run

**Requirement**: REQ-0700-OPERABILITY-002 | Rules: 34, 35 | Risk Pack: dynamic-config-hot-reload

#### Implementation Strategy

##### E02.1: Dry-run directive

- `markdown_dynconf_dry_run on | off;` — default off

##### E02.2: Dry-run flow

- On HUP signal when `dry_run=on`:
  1. Read new config file.
  2. Call FFI: `markdown_validate_config(new_config_bytes) → ValidationResult`.
  3. If valid: log "dry-run: configuration is valid", do NOT replace `active_snapshot`.
  4. If invalid: return all errors (line, field, reason), do NOT replace `active_snapshot`.

##### E02.3: Validation result

  ```rust
  #[repr(C)]
  pub struct FFIValidationResult {
      pub valid: u8,                  // 1 = valid, 0 = invalid
      pub errors: *mut FFIValidationError,
      pub error_count: usize,
  }

  #[repr(C)]
  pub struct FFIValidationError {
      pub line: u32,
      pub field: *const c_char,
      pub field_len: usize,
      pub reason: *const c_char,
      pub reason_len: usize,
  }
  ```

---

### TASK-E03: Dynconf Current Snapshot Introspection

- Query via diagnostics endpoint (E01).
- Ensure `header_filter` entry binds `active_snapshot` once; no per-field re-reads.

---

### TASK-E04: Dynconf Last-known-good and Rollback

**Requirement**: REQ-0700-OPERABILITY-004 | Rule: 35 | Risk Pack: dynamic-config-hot-reload

#### Implementation Strategy

##### E04.1: LKG preservation

- On successful reload:
  ```c
  /* Before replacing active_snapshot */
  shm_zone->last_known_good = shm_zone->active_snapshot;  /* copy */
  shm_zone->active_snapshot = new_config;
  shm_zone->applied_mtime = new_config.mtime;  /* ONLY after success */
  ```

##### E04.2: Manual rollback trigger

- New directive: `markdown_dynconf_rollback;` (signal-based or API-based)
- Sets `active_snapshot = last_known_good`, logs rollback event, increments `dynconf_rollbacks_total` metric.

##### E04.3: applied_mtime semantics

- Only update after successful reload (Rule 35).
- Validation failure → `applied_mtime` unchanged.

##### E04.4: Unknown key → NGX_ERROR

- If dynconf file contains unknown keys → reject, log, do not apply.

#### State Machine

```
[Normal] ──HUP──► [Validating] ──valid──► [Applying]
                     │                       │
                     │ invalid               │ success
                     ▼                       ▼
                  [Rejected]            [Active(N+1)]
                  (no change)          LKG = Active(N)
                                       applied_mtime updated

[Active(N+1)] ──rollback──► [Rolling Back] ──► [Active(N)]
                                                  LKG unchanged
```

---

### TASK-E05: Docs/Release Gates/Harness Alignment

- Create `tools/release/gates/validate_release_gates_070.py` — Python 3 validator.
- Update `docs/project/release-gates/0.7.0-release-gates.md` with actual check commands.
- Update Makefile with `release-gates-check-070` target.
- Update all docs: README, CONFIGURATION, SYSTEM_ARCHITECTURE, prometheus-metrics, CHANGELOG, AGENTS.md.
- Update `docs/harness/routing-manifest.json` with any new risk packs or verification families.

### TASK-E06: Validator Coverage

- Config validators: `decompression_budget`, `parse_timeout`, `parser_memory_budget`, `markdown_diagnostics`, `markdown_dynconf_dry_run`
- Metric validators: `decompression_*_total`, `parse_*_total`, `replay_buffer_errors_total`
- Reason-code validators: Rust enum + C define + docs + metrics label four-way alignment
- Package metadata validators: DEB control fields, RPM SPEC fields
- K8s manifest validators: `helm lint` + `kubectl apply --dry-run`

---

## 7. Phase 5: Final Validation

### TASK-F01: FFI ABI Final Verification

- Confirm all new struct fields are tail-appended.
- Confirm both C header copies are identical.
- Run layout tests + static_assert.

### TASK-F02: Configuration Compatibility

- Test: 0.6.x config runs under 0.7.0 with identical semantics.
- Verify new default values (decompression_budget=0 → input*20, parse_timeout=30s, etc.).

### TASK-F03: Coverage Gate

- `make coverage-c` — aggregate ≥ 80%, critical ≥ 90%
- `make coverage-rust` — aggregate ≥ 80%, critical ≥ 90%
- Run any new `#[ignore]` tests with `cargo test -- --ignored`.

### TASK-F04: Harness Full Verification

- `make harness-check-full && make docs-check && make release-gates-check-strict`

### TASK-F05: Version and Changelog

- Update Cargo.toml version, C header version macros, CHANGELOG.md, PROJECT_STATUS.md, VERSION_PLANNING.md.

---

## 8. FFI Change Registry

### 8.1 New Structs (0.7.0)

| Struct | Purpose | repr(C) | Task | Fields |
|--------|---------|---------|------|--------|
| `FFIDecompResult` | Bounded decompression result | Yes | A03 | `output: *mut u8`, `output_len: usize`, `error: DecompressionError`, `decompressed_size: usize` |
| `FFIAcceptResult` | Accept negotiation result | Yes | A05 | `decision: AcceptDecision`, `markdown_q: f32`, `html_q: f32`, `on_wildcard: u8` |
| `FFIConditionalResult` | Conditional request result | Yes | B02 | `is_not_modified: u8`, `new_etag: *mut c_char`, `new_etag_len: usize`, `reason_code: u32` |
| `FFIDecisionResult` | Decision engine result | Yes | B05 | `action: DecisionAction`, `reason_code: ReasonCode` |
| `FFIHeaderPlan` | Response header plan | Yes | B04 | `entries: *mut FFIHeaderEntry`, `count: usize` |
| `FFIHeaderEntry` | Single header operation | Yes | B04 | `action: HeaderAction`, `name: *const c_char`, `name_len: usize`, `value: *const c_char`, `value_len: usize` |
| `FFIUrlResult` | URL validation result | Yes | B03 | `is_valid: u8`, `base_url: *mut c_char`, `base_url_len: usize`, `error: u32` |
| `FFIValidationResult` | Config validation result | Yes | E02 | `valid: u8`, `errors: *mut FFIValidationError`, `error_count: usize` |
| `FFIValidationError` | Single validation error | Yes | E02 | `line: u32`, `field: *const c_char`, `field_len: usize`, `reason: *const c_char`, `reason_len: usize` |

### 8.2 Modified Structs (tail-append only)

| Struct | New Fields (appended to tail) | Task |
|--------|-------------------------------|------|
| `MarkdownOptions` | `decompression_budget: u64`, `parse_timeout_ms: u64`, `parser_memory_budget: u64` | A03, A06 |
| `MarkdownResult` | `decompression_error_category: u8`, `decision_reason_code: u32` | A04, B06 |

### 8.3 New FFI Functions

| Function | Signature | Ownership | Task |
|----------|-----------|-----------|------|
| `markdown_decompress_bounded` | `(input, len, budget) → FFIDecompResult` | Rust | A03 |
| `markdown_negotiate_accept` | `(header, len, on_wildcard) → FFIAcceptResult` | Rust | A05 |
| `markdown_make_decision` | `(context) → FFIDecisionResult` | Rust | B05 |
| `markdown_check_conditional` | `(if_none_match, if_modified_since, etag, last_modified) → FFIConditionalResult` | Rust | B02 |
| `markdown_validate_url` | `(url, forwarded_host, forwarded_proto) → FFIUrlResult` | Rust | B03 |
| `markdown_build_header_plan` | `(decision) → FFIHeaderPlan` | Rust | B04 |
| `markdown_get_reason_code_string` | `(code) → *const c_char` | Rust | B06 |
| `markdown_validate_config` | `(config_bytes, len) → FFIValidationResult` | Rust | E02 |
| `markdown_options_init` | `(opts: *mut MarkdownOptions)` | C helper | A07 |
| `markdown_options_cleanup` | `(opts: *mut MarkdownOptions)` | C helper | A07 |
| `markdown_ffidecomp_result_release` | `(result: *mut FFIDecompResult)` | C helper | A03 |
| `markdown_ffiaccept_result_release` | `(result: *mut FFIAcceptResult)` | C helper | A05 |
| `markdown_fficonditional_result_release` | `(result: *mut FFIConditionalResult)` | C helper | B02 |
| `markdown_ffiheader_plan_release` | `(plan: *mut FFIHeaderPlan)` | C helper | B04 |
| `markdown_ffiurl_result_release` | `(result: *mut FFIUrlResult)` | C helper | B03 |
| `markdown_ffivalidation_result_release` | `(result: *mut FFIValidationResult)` | C helper | E02 |

### 8.4 New FFI Enums

| Enum | Variants | repr(C) | Task |
|------|----------|---------|------|
| `DecompressionError` | OK=0, BudgetExceeded=1, FormatError=2, TruncatedInput=3, IoError=4 | Yes | A03 |
| `AcceptDecision` | Convert=0, Skip=1, Reject=2, NoAccept=3 | Yes | A05 |
| `DecisionAction` | Convert=0, Skip=1, FailOpen=2, FailClosed=3 | Yes | B05 |
| `HeaderAction` | Set=0, Delete=1, Modify=2 | Yes | B04 |
| `ReasonCode` | Converted=0, SkippedAccept=1, SkippedNoAccept=2, SkippedConditional=3, FailedDecompressionBudget=4, FailedDecompressionFormat=5, FailedDecompressionTruncated=6, FailedDecompressionIo=7, ParseTimeout=8, ParseBudgetExceeded=9, ReplayBufferError=10, FfiCallError=11 | Yes | B05 |

---

## 9. Configuration Directive Registry

### 9.1 New Directives (0.7.0)

| Directive | Context | Default | Value Range | FFI Options Field | Validator | Task |
|-----------|---------|---------|-------------|-------------------|-----------|------|
| `markdown_decompression_budget` | http, server, location | 0 (→ input*20) | 0 or ≥ 1024 | `MarkdownOptions.decompression_budget` | size validator | A03 |
| `markdown_parse_timeout` | http, server, location | 30s | 1s–300s | `MarkdownOptions.parse_timeout_ms` | time validator | A06 |
| `markdown_parser_budget` | http, server, location | 64m | 1m–1g | `MarkdownOptions.parser_memory_budget` | size validator | A06 |
| `markdown_diagnostics` | http, server, location | off | on, off | (C-side flag) | on/off validator | E01 |
| `markdown_diagnostics_allow` | http, server, location | (none) | CIDR | (C-side ACL) | CIDR validator | E01 |
| `markdown_dynconf_dry_run` | http, server, location | off | on, off | (C-side flag) | on/off validator | E02 |
| `markdown_dynconf_rollback` | http, server, location | — | (signal-only) | (C-side action) | — | E04 |

### 9.2 Modified Directives

| Directive | Change | Task |
|-----------|--------|------|
| `markdown_on_wildcard` | Behavior now driven by Rust negotiate_accept | A05 |

---

## 10. Reason Code and Metric Registry

### 10.1 New Reason Codes

| Reason Code | Rust Enum Value | C FFI Constant | Trigger | Metric | log_decision() Callsite | Task |
|-------------|----------------|----------------|---------|--------|------------------------|------|
| DECOMPRESSION_BUDGET_EXCEEDED | FailedDecompressionBudget=4 | FFI_RC_DECOMPRESSION_BUDGET_EXCEEDED | Decompress output > budget | decompression_budget_exceeded_total | bounded.rs:decompress() | A03 |
| DECOMPRESSION_FORMAT_ERROR | FailedDecompressionFormat=5 | FFI_RC_DECOMPRESSION_FORMAT_ERROR | Malformed compressed input | decompression_format_error_total | bounded.rs:decompress() | A04 |
| DECOMPRESSION_TRUNCATED_INPUT | FailedDecompressionTruncated=6 | FFI_RC_DECOMPRESSION_TRUNCATED_INPUT | Incomplete compressed stream | decompression_truncated_input_total | bounded.rs:decompress() | A04 |
| DECOMPRESSION_IO_ERROR | FailedDecompressionIo=7 | FFI_RC_DECOMPRESSION_IO_ERROR | IO error during decompress | decompression_io_error_total | bounded.rs:decompress() | A04 |
| PARSE_TIMEOUT | ParseTimeout=8 | FFI_RC_PARSE_TIMEOUT | Parse duration > timeout | parse_timeouts_total | parse_budget.c:timer_handler() | A06 |
| PARSE_BUDGET_EXCEEDED | ParseBudgetExceeded=9 | FFI_RC_PARSE_BUDGET_EXCEEDED | Parser allocated > budget | parse_budget_exceeded_total | engine.rs:parse_with_budget() | A06 |
| REPLAY_BUFFER_ERROR | ReplayBufferError=10 | FFI_RC_REPLAY_BUFFER_ERROR | Replay buffer init/append fail | replay_buffer_errors_total | buffer.c:replay_append() | A01 |
| SKIPPED_ACCEPT_REJECT | SkippedAccept=1 | FFI_RC_SKIPPED_ACCEPT | Accept q=0 explicit reject | (decision counter only) | accept.rs:negotiate_accept() | A05 |
| SKIPPED_NO_ACCEPT | SkippedNoAccept=2 | FFI_RC_SKIPPED_NO_ACCEPT | No Accept header | (decision counter only) | accept.rs:negotiate_accept() | A05 |
| SKIPPED_CONDITIONAL | SkippedConditional=3 | FFI_RC_SKIPPED_CONDITIONAL | If-None-Match or If-Modified-Since match | (decision counter only) | etag.rs:check_conditional() | B02 |

### 10.2 New Metrics

| Metric Name | Type | Unit | Labels | Lifecycle Steps | Task |
|-------------|------|------|--------|----------------|------|
| `decompression_budget_exceeded_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A03 |
| `decompression_format_error_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A04 |
| `decompression_truncated_input_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A04 |
| `decompression_io_error_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A04 |
| `parse_timeouts_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A06 |
| `parse_budget_exceeded_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A06 |
| `replay_buffer_errors_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | A01 |
| `markdown_decisions_total` | counter | — | reason | SHM field → snapshot → Prometheus → docs → validator | B06 |
| `markdown_delivery_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | B06 |
| `dynconf_rollbacks_total` | counter | — | — | SHM field → snapshot → Prometheus → docs → validator | E04 |

### 10.3 Complete Lifecycle Checklist (per metric)

Each new metric must complete ALL of these steps:

1. ☐ SHM zone struct field defined (C)
2. ☐ SHM zone snapshot copies field (C)
3. ☐ JSON renderer outputs field (C)
4. ☐ Text renderer outputs field (C)
5. ☐ Prometheus renderer outputs field (C)
6. ☐ Runtime write site (METRIC_INC/METRIC_ADD) in correct code path (C/Rust)
7. ☐ Unit test triggers metric increment
8. ☐ `docs/guides/prometheus-metrics.md` updated
9. ☐ Release gate validator checks name matches Rust key
10. ☐ Reason code log_decision() callsite exists

---

## 11. Risk Routing Matrix

| Task | Primary Risk Pack | Supporting Packs | Verification Families | AGENTS.md Rules |
|------|-------------------|------------------|----------------------|----------------|
| A01 | runtime-streaming | observability-metrics, nginx-protocol-safety | harness-sync, nginx-streaming, runtime-e2e, e2e-harness-rust | 1, 2, 38 |
| A02 | nginx-protocol-safety | runtime-streaming, ffi-boundary | harness-sync, nginx-protocol, runtime-e2e | Baseline |
| A03 | ffi-boundary | runtime-streaming, observability-metrics | harness-sync, ffi-boundary, rust-streaming | 3, 32 |
| A04 | ffi-boundary | runtime-streaming, observability-metrics | harness-sync, ffi-boundary | 15 |
| A05 | nginx-protocol-safety | ffi-boundary, e2e-migration | harness-sync, nginx-protocol, e2e-harness-rust | 27 |
| A06 | runtime-streaming | observability-metrics, ffi-boundary | harness-sync, rust-streaming, nginx-streaming | 3, 7, 17 |
| A07 | ffi-boundary | docs-tooling-drift | harness-sync, ffi-boundary, release-quality | 15 |
| B01 | ffi-boundary | nginx-protocol-safety, output-safety | harness-sync, docs-tooling | 15 |
| B02 | ffi-boundary | nginx-protocol-safety, observability-metrics | harness-sync, ffi-boundary, e2e-harness-rust | 15, 27 |
| B03 | ffi-boundary | nginx-protocol-safety, output-safety | harness-sync, ffi-boundary | 27 |
| B04 | ffi-boundary | nginx-protocol-safety, output-safety | harness-sync, ffi-boundary | 15, 29 |
| B05 | ffi-boundary | observability-metrics, nginx-protocol-safety | harness-sync, ffi-boundary | 7, 23 |
| B06 | ffi-boundary | observability-metrics, docs-tooling-drift | harness-sync, observability-metrics, release-quality | 7, 8, 8b, 8c, 23 |
| C01-C06 | packaging-distribution | release-governance, docs-tooling-drift | harness-sync, docs-tooling, release-governance-060, packaging-e2e | 9, 12 |
| D01-D05 | packaging-distribution | nginx-protocol-safety, docs-tooling-drift | harness-sync, docs-tooling, packaging-e2e | 9 |
| E01 | dynamic-config-hot-reload | observability-metrics, nginx-protocol-safety | harness-sync, nginx-protocol | 7, 8, 23 |
| E02 | dynamic-config-hot-reload | observability-metrics | harness-sync, docs-tooling, nginx-protocol | 34, 35 |
| E03 | dynamic-config-hot-reload | observability-metrics, nginx-protocol-safety | harness-sync, nginx-protocol | 34, 35 |
| E04 | dynamic-config-hot-reload | observability-metrics, nginx-protocol-safety | harness-sync, nginx-protocol, runtime-e2e | 35 |
| E05 | docs-tooling-drift | release-governance, harness-remediation | harness-sync, docs-tooling, release-quality | 9, 13, 21 |
| E06 | docs-tooling-drift | release-governance, harness-remediation | harness-sync, docs-tooling, release-quality | 7, 8, 9, 23 |

---

## 12. Open Questions and Deferred Items

| ID | Question | Status | Decision Point | Impact if Deferred |
|----|----------|--------|----------------|-------------------|
| OQ1 | musl/libc static-linked build in 0.7.0 matrix | Deferred to 0.8.0 | User demand | No musl/alpine packages in 0.7.0 |
| OQ2 | F5 Ingress Controller module injection feasibility | Pending | TASK-D04 | K8s docs limited to OSS NGINX |
| OQ3 | Diagnostics endpoint access control (allow CIDR vs auth) | Pending | TASK-E01 | Default deny, may need auth for prod |
| OQ4 | Decompression budget default 20x multiplier | Pending benchmark | Phase 0 | May adjust default after perf testing |
| OQ5 | Homebrew tap inclusion | Deferred to 0.8.0 | Package priority | No macOS Homebrew in 0.7.0 |
| OQ6 | Dynconf dry-run validation timeout threshold | Pending config | TASK-E02 | Default 5s, may need tuning |
| OQ7 | Parser interrupt feasibility (true mid-parse stop) | Documented as infeasible | TASK-A06 | Use depth/node-count limits instead |
| OQ8 | Rust incremental converter feature in 0.7.0 | No change | — | Already feature-gated, no new work |

---

## 13. Document Updates

| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 0.7.0-draft | 2026-05-17 | spec-design-agent | Initial v0.7.0 implementation design |
