Skip to content

v1.4 — Audit Remediation & Truth Reconciliation

Document ID: RM-AR-001 File Path: docs/18-roadmap/v1.4-audit-remediation.md Version: 1.1.0 Status: In progress — 11 of 20 tickets done. All of Phase 1 (SEC-401/402/405/406, AIC-301/302) and the security/QA half of Phase 2 (SEC-403/404, QA-401/402/403) have shipped. Outstanding: STR-501 (version/maturity reconciliation) and STR-502 (claim-honesty pass) from Phase 2, and all of Phase 3 (AIC-303/304/305, WFL-309, STR-503/504/505). Each ticket below carries its own DONE/PLANNED marker — those are authoritative. Owner: Engineering (Security / AI Core / Platform / DX) Last Updated: 2026-08-01


Execute PRD-007: remediate the concrete, file:line-backed findings of the 2026-07-23 four-lens audit (QA, AI engineering, security, codebase-health/strategy) and reconcile the product’s claims with its reality. This is a truth-and-hardening milestone — every ticket is a fix, a reconciliation, or a scope reduction; no new product surface. Requirement IDs (SEC-4xx / AIC-3xx / WFL-309 / QA-4xx / STR-5xx) are defined in PRD-007 §6; tickets here carry the RM-AR-P<phase> prefix and reference them.

Format matches RM-AIM-P1: problem + file:line evidence, change, acceptance criteria, files, size (S ≈ ≤2 days, M ≈ 3–5 days, L ≈ 1–2 weeks), dependencies, priority.


── Phase 1 (stop the bleeding — small, high-value, ship-blockers) ──
SEC-401 (SSRF redirect) ── SEC-406 (SSRF ranges) [same guard, do together]
SEC-402 (cross-tenant org authz) [independent, tiny]
SEC-405 (KMS fail-closed on missing key) [independent]
AIC-301 (Anthropic/embedding fail-loud) [independent]
AIC-302 (bounded caches) [independent]
── Phase 2 (make claims true — trust integrity & honesty) ──
SEC-403 (audit keyed MAC + head anchor) ── depends on SEC-405's key-sourcing
SEC-404 (sandbox floor + claim scoping)
QA-401 (wire proven tests into CI) ── QA-403 (MinIO/PG-TLS/xproc-KMS legs)
QA-402 (coverage floor)
STR-501 (version reconciliation) ── STR-502 (claim-honesty pass) [after SEC/AIC land]
── Phase 3 (quality & sustainability) ──
AIC-303 (default keyword retrieval) ── AIC-304 (multimodal tokens) ── AIC-305 (reasoning params)
WFL-309 (workflow ref validation)
STR-503 (subsystem freeze + experimental labels)
STR-504 (SigV4 vendor-or-fence) ── depends on QA-403's MinIO leg
STR-505 (wire-or-cut unwired features)

Land SEC-401 and SEC-402 first. They are small and they are the findings that end a security review before it starts. AIC-301 is P1 because an Anthropic-only appliance is a documented first-run configuration that is currently broken on every memory/RAG call.


Theme: critical correctness and security defects; each is small and high-value. Acceptance is a regression test that fails against pre-fix code.

SEC-401 [P0] — SSRF via HTTP redirect — DONE

Section titled “SEC-401 [P0] — SSRF via HTTP redirect — DONE”

Problem. pinned_client builds a DNS-pinned reqwest::Client but does not set redirect(Policy::none()) (crates/wovyr-tools/src/builtin.rs:401-409), and resolve_and_guard runs once against the original host only (builtin.rs:366-395, called at :466-468; MCP Http transport identically at crates/wovyr-tools/src/mcp.rs:314-315). reqwest follows up to 10 redirects by default, and .resolve() pins DNS only for the original host — so a 302 Location: http://169.254.169.254/... reaches the metadata IP unguarded. (PRD-007 finding 1; audit High.)

Change. Set a custom redirect policy on the pinned client that either rejects redirects for tool fetches, or re-runs resolve_and_guard on each hop’s Location before following. Apply in the one shared helper so both http_get and the MCP Http transport inherit it.

Acceptance criteria.

  • A test stands up a local server that 302-redirects to a loopback/private/ metadata address; http_get (and the MCP Http connect path) refuses, fail-closed, and the test fails against the current code.
  • A benign single-hop redirect to a public, guard-passing host still succeeds (no over-blocking of legitimate redirects, if the policy allows re-guarded hops).

Files. crates/wovyr-tools/src/builtin.rs, crates/wovyr-tools/src/mcp.rs. Size. S. Depends on: none. Do together with: SEC-406 (same guard).

Done (2026-07-24). pinned_client now sets redirect(Policy::none()), so the DNS-pinned client (shared by http_get and the MCP Http transport) never auto-follows a Location: to an unpinned host. http_get follows redirects manually via get_following_guarded_redirects, re-running resolve_and_guard (SSRF guard + DNS pin) on every hop and bounding the chain at MAX_REDIRECTS (10); a relative Location is joined against the current URL and a non-http(s) target fails closed on the next hop. The MCP transport reuses the same no-redirect client, so a 3xx surfaces as a fail-closed transport error rather than being followed. Regression tests (crates/wovyr-tools/src/builtin.rs): a local server 302-redirecting to 169.254.169.254 is refused (http_get_refuses_a_redirect_to_the_metadata_address), a benign re-guarded two-hop redirect succeeds (http_get_follows_a_benign_re_guarded_redirect), and a self-redirect loop fails closed (http_get_fails_closed_on_a_redirect_loop) — the redirect follower takes an injectable guard so the path is exercisable against a loopback test server without a public host.

SEC-402 [P0] — Cross-tenant org-level authorization gap — DONE

Section titled “SEC-402 [P0] — Cross-tenant org-level authorization gap — DONE”

Problem. tenancy::context() grants an Organization-scoped membership’s role whenever the request is project-less, via project_org.as_deref() == Some(o.as_str()) || project.is_none() (crates/wovyr-server/src/tenancy.rs:118-120). X-Wovyr-Tenant is an unverified client header (:92-94). So an OrgAdmin in tenant A can send X-Wovyr-Tenant: B and pass authorize("org.admin") for org-level routes — create_org (:186-189), list_orgs/list_projects (:159-166, :237-244) — with no membership in B. Project-level ops are protected (the project_org == o arm); org-level ops are not. (PRD-007 finding 2; audit Medium-High.)

Change. Remove the || project.is_none() escape; an org-scoped role must match the target org (resolved from the request’s tenant/org context) for org-level operations too. Where a genuinely tenant-global platform operation needs to remain open, gate it on the explicit platform.admin role, not on the absence of a project.

Acceptance criteria.

  • A regression test added to the SEC-105 authz matrix: a principal who is OrgAdmin only in tenant A receives 403 on GET /api/v1/organizations and POST /api/v1/organizations with X-Wovyr-Tenant: B. Fails against current code.
  • Existing same-tenant org-admin operations still succeed (no regression for legitimate members).

Files. crates/wovyr-server/src/tenancy.rs (+ the SEC-105 matrix test). Size. S. Depends on: none.

Done (2026-07-24). context()’s org-scoped-membership arm dropped the || project.is_none() escape. A project-scoped request still resolves an org role against the in-scope project’s owning org (unchanged); a project-less (org-level) request now applies an org role only when state.tenancy.get_org(o)’s tenant equals the request’s tenant — so an OrgAdmin in tenant A spoofing X-Wovyr-Tenant: B resolves to zero roles and fails closed (default-deny → 403). Tenant-global operations remain gated on platform.admin (pushed before the membership loop), not on the absence of a project. Regression test in the SEC-105 matrix (crates/wovyr-server/tests/authz_matrix.rs::org_admin_cannot_cross_tenants_on_org_level_routes): an OrgAdmin of a tenant-A org gets 403 on GET/POST /api/v1/organizations and GET /api/v1/projects with X-Wovyr-Tenant: tenant-b, while the same principal’s legitimate same-tenant org.admin operations still succeed.

SEC-405 [P1] — KMS fail-closed on missing durable key material — DONE

Section titled “SEC-405 [P1] — KMS fail-closed on missing durable key material — DONE”

Problem. The server’s default_kms falls back to a fully ephemeral in-process root key when HOME/~/.wovyr is unresolvable (logged loudly, per CLAUDE.md’s wovyr-server bullet). In a container with no persistent volume this silently seals data under a key that vanishes on restart — every sealed secret/ memory becomes unrecoverable, with no startup failure. (PRD-007 finding 5; audit Medium.)

Change. Refuse to start (fail-closed, Error::Config) when neither WOVYR_KMS_ROOT_KEY nor a persistent, writable key file is available, instead of minting an ephemeral key. Keep an explicit, loudly-named opt-in (WOVYR_KMS_ALLOW_EPHEMERAL=1) for the genuine ephemeral/test case, so the unsafe path is reachable only by deliberate operator choice — the WOVYR_ALLOW_ANONYMOUS precedent.

Acceptance criteria.

  • A test asserts that with no root-key env var and an unwritable/absent key directory, KMS construction returns a clear config error rather than an ephemeral key.
  • The WOVYR_KMS_ALLOW_EPHEMERAL=1 path still yields an ephemeral key (test/dev unblocked), with a warning.

Files. crates/wovyr-server/src/config.rs (default_kms), crates/wovyr-config/src/kms.rs (shared constructor), crates/wovyr-kms/src/root.rs. Size. S. Depends on: none. Blocks: SEC-403 (shares key sourcing).

Done (2026-07-24). wovyr_config::kms::build_kms now returns Result<Arc<dyn Kms>> and fails closed (Error::Config) when neither WOVYR_KMS_ROOT_KEY nor a persistent, writable key file is available, instead of minting an ephemeral in-process key. The explicit opt-out is WOVYR_KMS_ALLOW_EPHEMERAL=1 (parsed like WOVYR_ENABLE_SHELL_TOOL), which still yields a working ephemeral key with a loud warning. The decision lives in a pure build_kms_inner(dir, env_key, allow_ephemeral) so it’s testable without mutating the process-global HOME/WOVYR_KMS_ROOT_KEY. The server’s default_kms unwraps the result with a “refusing to start” panic (a startup misconfiguration halts the process — AppState::from_env stays infallible, so its ~60 call sites are untouched); the CLI’s config::kms exits with a clear stderr message. Tests (crates/wovyr-config/src/kms.rs): missing_key_material_fails_closed (no env key + no dir + no opt-in → config error naming the fix), ephemeral_opt_in_yields_a_key, and a_persistent_key_directory_yields_a_durable_kms. wovyr-kms/src/root.rs was left unchanged — its from_file generate-once behavior is the persistent path the fix relies on; the fail-closed decision belongs one level up, at the shared constructor that chooses between env / file / ephemeral.

SEC-406 [P1] — SSRF blocklist: encapsulated & CGNAT ranges — DONE

Section titled “SEC-406 [P1] — SSRF blocklist: encapsulated & CGNAT ranges — DONE”

Problem. is_blocked_ip (crates/wovyr-tools/src/builtin.rs:338-358) covers IPv4 loopback/link-local/private/broadcast/unspecified and IPv6 loopback/ULA/ link-local + IPv4-mapped, but misses 6to4 (2002:a9fe:a9fe:: wraps 169.254.169.254), NAT64 (64:ff9b::/96), IPv4-compatible IPv6 (::/96), and CGNAT (100.64.0.0/10, RFC 6598). A DNS-controlling attacker can return such an address to reach metadata/internal hosts on hosts with that routing. (PRD-007 finding 6; audit Medium.)

Change. Extend is_blocked_ip to cover these ranges, decoding 6to4/NAT64/ IPv4-compatible addresses to their embedded IPv4 and re-checking that against the existing IPv4 rules. Same shared guard as SEC-401.

Acceptance criteria.

  • Table-driven test with one blocked case per added range (6to4-wrapped metadata, NAT64-wrapped private, CGNAT), each refused; a public IPv6 address still passes.

Files. crates/wovyr-tools/src/builtin.rs. Size. S. Depends on: none. Do together with: SEC-401.

Done (2026-07-24). is_blocked_ip now also blocks CGNAT (100.64.0.0/10, RFC 6598) on the IPv4 path, and decodes every IPv6 form that embeds an IPv4 — 6to4 (2002::/16), NAT64 (64:ff9b::/96), and the deprecated IPv4-compatible (::a.b.c.d, excluding ::/::1 which the existing classifiers handle) — to its embedded IPv4 and re-checks that against the IPv4 rules. The table-driven is_blocked_ip_classifies_internal_and_metadata_ranges test gained a blocked case per range (6to4-/NAT64-/IPv4-compatible-wrapped metadata and private, CGNAT low/high ends) plus over-block guards (a 6to4-wrapped public 8.8.8.8 and addresses just outside CGNAT stay allowed).

AIC-301 [P1] — Anthropic-only breaks memory/RAG; fail loud — DONE

Section titled “AIC-301 [P1] — Anthropic-only breaks memory/RAG; fail loud — DONE”

Problem. AnthropicProvider implements no embed, inheriting the default “does not support embeddings” error (crates/wovyr-provider/src/provider.rs:75-80); Gateway::embed is a no-failover pass-through to providers.first() (gateway.rs:529-535). A deployment with only ANTHROPIC_API_KEY therefore errors on every MemoryEngine::remember*/query (engine.rs:152, :619-629) — a first-class, documented config silently broken deep inside a run. (PRD-007 finding 7; audit High.)

Change.

  • Allow a distinct embedding provider in the gateway (resolve embeddings independently of the chat provider), and/or add an Anthropic-side embedding path (e.g. Voyage) behind a feature/config.
  • Regardless: if a memory/RAG-enabled agent or route is configured on a deployment whose resolved embedding provider cannot embed, fail at config/startup time with a clear message (“no embedding provider configured; set OPENAI_API_KEY or WOVYR_EMBED_PROVIDER, or disable memory”), never per-call. One-release observe-then-enforce, per PRV-101’s rollout.

Acceptance criteria.

  • A test with only an Anthropic-class chat provider and a memory-enabled agent fails fast at construction with the config error, not at the first query.
  • With a configured embedding provider, memory works end to end (existing memory tests pass unchanged).

Files. crates/wovyr-provider/src/{gateway.rs,provider.rs}, crates/wovyr-memory/src/engine.rs, crates/wovyr-server/src/config.rs, crates/wovyr-cli wiring. Size. M. Depends on: none.

Done (2026-07-24). Added AIProvider::supports_embeddings() (default false; MockProvider/OpenAiProvider override true, AnthropicProvider stays false). The Gateway gained an optional dedicated embedding provider (with_embedding_provider, resolved independently of the chat chain — so an Anthropic-chat deployment can serve embeddings through a separate provider) and supports_embeddings(), which reports whether the provider embed would route to can embed; embed/resolve_embedding_model now key off that effective embed provider. MemoryEngine::try_new(gateway, store) fails closed (Error::Config, actionable message) when the gateway can’t embed, alongside the unchanged new (kept for tests/internal use with a known-capable gateway). The server’s default_engine returns Result and AppState::from_env refuses to start (panic) on the error — the memory routes are always mounted, so an embedding-less deployment is a startup misconfig, not a per-call surprise; the CLI’s engine() propagates the error (fails memory commands and memory-grounded runs fast). AppState::from_env stays infallible otherwise (SEC-405 precedent), so its ~60 call sites are untouched — and the default offline path is unaffected because MockProvider embeds. Tests: wovyr-provider/src/gateway.rs (supports_embeddings_reflects_the_effective_provider, a_dedicated_embedding_provider_makes_a_chat_only_gateway_embed) and wovyr-memory/src/engine.rs (try_new_fails_closed_without_an_embedding_provider using an Anthropic-class gateway — the acceptance criterion — try_new_succeeds_with_an_embedding_capable_gateway). Scope note: a Voyage/Anthropic-side embedding provider and an WOVYR_EMBED_PROVIDER env knob were not built — from_env never yields a non-embedding gateway when OPENAI_API_KEY is present (OpenAI is chosen for chat and embeds), so the only from_env failure is genuinely Anthropic-only-with-no-OpenAI, where there is no alternate embedder to attach; the with_embedding_provider seam is in place for when a native Anthropic-side embedder is added.

AIC-302 [P1] — Bound the exact + semantic caches — DONE

Section titled “AIC-302 [P1] — Bound the exact + semantic caches — DONE”

Problem. The exact cache’s cache_store only ever inserts; TTL is checked on lookup, so expired entries are never removed (crates/wovyr-provider/src/gateway.rs:564-574). InMemorySemanticCache::store pushes onto a Vec on every miss and never evicts; lookup is a linear cosine scan over every entry ever stored (resilience.rs:568-595, :597-616). Both memory and per-request latency grow without bound in a long-running server. (PRD-007 finding 8; audit Medium-High.)

Change. Add a size cap + LRU eviction and a periodic/opportunistic TTL sweep to the exact cache; a bounded entry count (with eviction) and a capped scan to the semantic cache — behind the existing store traits, no public API change.

Acceptance criteria.

  • A test inserts well past the cap and asserts the cache’s entry count stays bounded and expired entries are evicted (not merely ignored on lookup).
  • A test asserts the semantic cache’s stored-entry count is bounded under a flood of misses.

Files. crates/wovyr-provider/src/{gateway.rs,resilience.rs}. Size. M. Depends on: none.

Done (2026-07-24). The exact cache is now an ExactCache (crates/wovyr-provider/src/resilience.rs): a bounded HashMap wrapped in the gateway’s existing Mutex, with a monotonic access tick for LRU. insert TTL-sweeps expired entries first (so an entry looked-up-but-never-reinserted is reclaimed, not merely ignored) then LRU-evicts down to CacheConfig.max_entries (new field, default 10,000; 0 disables exact caching); get refreshes recency and removes an expired entry on access. cache_lookup/cache_store in gateway.rs delegate to it, and with_cache rebuilds the cache with the configured cap. InMemorySemanticCache gained a max_entries bound (default 10,000, with_max_entries to override): store evicts the oldest entries once over cap, so both the stored count and the linear cosine scan stay bounded — no public trait change. Tests (resilience.rs): exact_cache_stays_bounded_and_evicts_expired (flood past cap stays bounded; an expired entry is evicted on the next insert, not accumulated), exact_cache_evicts_least_recently_used (a touched entry survives over an older untouched one), and semantic_cache_stays_bounded_under_a_flood_of_misses (200 distinct-vector misses into a cap-8 cache stays ≤ 8). Scope note: the exact cap threads through CacheConfig; the semantic cap is a property of InMemorySemanticCache (a pluggable SemanticCacheStore — a Qdrant backend has its own bounds), defaulting to 10,000 with with_max_entries for tuning.


Theme: trust integrity and honesty. Upgrade the two overstated headline claims, and make “proven” mean “proven in CI.”

SEC-403 [P1] — Audit log: keyed MAC + tamper-evident head anchor — DONE

Section titled “SEC-403 [P1] — Audit log: keyed MAC + tamper-evident head anchor — DONE”

Problem. The audit chain is an unkeyed SHA-256 over prev_hash + (id, seq, event) (crates/wovyr-audit/src/log.rs:36-43, verify at :466-492). The hash is public, there is no keyed MAC and no external anchor. Any actor who can write audit.jsonl can rewrite entries and recompute the chain; verify() then passes. Tail truncation is undetectable — no persisted head/high-water-mark. The fsync durability and concurrent-append fix are real but are consistency, not tamper resistance. This is the single most important gap for the “EU-AI-Act logging / passes security review” positioning. (PRD-007 finding 3; audit High.)

Change.

  • Replace the unkeyed chain hash with an HMAC keyed by a secret held outside the log file, sourced like the KMS root key (WOVYR_KMS_ROOT_KEY/escrowed file; reuse SEC-405’s fail-closed-on-missing-key stance).
  • Persist a monotonic head anchor (highest seq + its MAC) durably and separately, so verify() detects tail truncation, not just interior edits.
  • Document an optional external-anchor/notarization hook (periodic head-hash to WORM/transparency storage) for the compliance tier; implementing the hook itself may be a follow-on, but the interface lands here.

Acceptance criteria.

  • verify() fails on (a) a rewritten interior entry and (b) a truncated tail — both proven by tests that mutate audit.jsonl directly and assert detection.
  • Without the MAC key, the log fails closed (won’t open/verify), same posture as SEC-405.
  • The existing concurrent-append and fsync-durability tests still pass.

Files. crates/wovyr-audit/src/log.rs, key sourcing shared with crates/wovyr-kms/src/root.rs. Size. M. Depends on: SEC-405 (key sourcing). Note: on-disk audit format changes — acceptable pre-real-deployment (same stance as API-702’s breaking change), documented in the ticket.

Done (2026-07-24). AuditLog::open_keyed(sink, key, dir) chains each entry with a keyed HMAC-SHA256 (chain_hash now takes Option<&[u8;32]>; unkeyed open/ in_memory keep the plain SHA-256 chain for tests/single-process) an actor who can rewrite audit.jsonl cannot recompute without the externally-held key. A monotonic head anchor ({seq, hash, mac}, MAC = keyed HMAC over the pair, domain-separated from the chain MAC) is atomically written to a separate audit.head on every append — written after the entry so a crash leaves the log ahead of (never behind) the anchor. verify() recomputes the keyed chain and, for a keyed log, checks the anchor: a MAC mismatch (forged anchor), a log shorter than the anchor’s seq (tail truncation), a diverged anchored entry, or a missing anchor on a non-empty log all fail closed. The MAC key is sourced by the new wovyr_config::audit::build_audit_key (mirroring build_kms/SEC-405): WOVYR_AUDIT_MAC_KEY (hex) or a generate-once ~/.wovyr/audit/ audit.key via wovyr_kms::root::from_env/from_file — a distinct key from the KMS root (rotating one must not invalidate the other) — fail-closed on missing material, with WOVYR_AUDIT_ALLOW_UNKEYED=1 the explicit test opt-out (Ok(None) → the unkeyed path). The server’s default_audit_log opens keyed by default and panics (“refusing to start”) on a sourcing error (SEC-405 precedent). NotarizationHook + NotarizedHead land the external-anchor (WORM/transparency-log) interface for the compliance tier (best-effort — a hook error is logged, not fatal); the concrete publisher is a follow-on. Tests (crates/wovyr-audit/src/log.rs): keyed_log_detects_an_interior_edit (acceptance a), keyed_log_detects_tail_truncation (acceptance b), keyed_head_anchor_tamper_is_ detected, keyed_log_fails_verify_under_the_wrong_key, keyed_chain_hash_differs_from_ unkeyed, keyed_log_persists_and_continues_across_reopen, notarization_hook_receives_ each_head; the existing concurrent_append/fsync tests pass unchanged; sourcing fail-closed proven in crates/wovyr-config/src/audit.rs (missing_key_material_fails_closed, unkeyed_opt_in_yields_no_key, a_persistent_directory_yields_a_generated_key). On-disk format change (HMAC hashes + new audit.head) is the documented breaking change — a pre-existing SHA-256 audit.jsonl won’t verify under a keyed log, acceptable pre-real-deployment. Scope note: the external notary is an interface only, per the ticket (“the interface lands here”).

SEC-404 [P1] — Sandbox confinement floor + claim scoping — DONE

Section titled “SEC-404 [P1] — Sandbox confinement floor + claim scoping — DONE”

Problem. The default trust class is FirstPartySandboxBackend::Native (crates/wovyr-tools/src/sandbox/types.rs:66, :76-82). NativeSandbox enforces only timeout/output-cap/setrlimit(Unix)/Job-Object(Windows) — no filesystem confinement (just current_dir, native.rs:44-52) and no network isolation (NetworkPolicy is never referenced; builtin.rs:966-967 admits “a native run always has full host network access”). The real isolation (--network none/egress-lockdown/gVisor) is Linux+Docker + non-first-party only. So first-party shell/code_execute on Windows/macOS (and by default) is unsandboxed and can read ~/.wovyr/kms/root.key and exfiltrate it. “Sandboxed tools” is misleading as an unqualified claim. (PRD-007 finding 4; audit High.)

Change.

  • Add a confinement floor to the native path for shell/code_execute where the host supports it (e.g. deny-by-default egress and a workdir-scoped filesystem view where an OS mechanism exists), reusing the existing NetworkPolicy type that native currently ignores.
  • Where a real isolation floor is unavailable (first-party native on Windows/macOS), make the behavior an explicit, documented choice: fail-closed refusal with a clear message, or an explicitly operator-acknowledged unsandboxed run — never a silent one.
  • Scope the “sandboxed” claim in code docs, README.md, and DISTRIBUTION.md to exactly where it holds (feeds STR-502).

Acceptance criteria.

  • A test asserts native shell cannot reach the network when the floor is active (on a platform that supports it), and that on an unsupported platform the run is either refused or emits the explicit unsandboxed-acknowledgement path — never a silent full-access run.
  • Docs no longer contain an unqualified “sandboxed tools” claim.

Files. crates/wovyr-tools/src/sandbox/native.rs, crates/wovyr-tools/src/builtin.rs, docs. Size. L. Depends on: none. Note: cross-platform parity to the Linux+Docker path is explicitly not the bar (PRD-007 §4.2); an honest scoped claim + a floor where feasible is.

Done (2026-07-25). NativeSandbox gained with_network(NetworkPolicy): a deny-all policy activates a Linux confinement floor by running the child inside an unprivileged network namespace (unshare --map-root-user --net, no interfaces configured — no route out); availability is probed once via NativeSandbox::network_isolation_available() (Linux: a real unshare capability check; every other platform: always false, since there is no equivalent native mechanism — an explicit non-goal per PRD-007 §4.2, not an oversight). run() re-checks availability itself before honoring a deny-all request, so the floor can never be silently skipped even if a caller bypasses the tool-layer gate.

At the tool layer, builtin.rs’s resolve_native_confinement is the shared decision ShellTool/CodeExecuteTool both call before any native run: (1) confine via the floor where the host supports it; else (2) proceed only as an explicitly-acknowledged unsandboxed runnative_only() (the CLI/trusted-local default) is itself the acknowledgement, or a hosted with_manager(...) build honors the operator opt-in WOVYR_ALLOW_UNSANDBOXED_NATIVE=1 — logging a loud one-time warning; else (3) fail closed (ToolError::PermissionDenied, a clear message naming the fix) — never a silent full-host-access run. ShellTool/CodeExecuteTool gained with_unsandboxed_native_ack to override the acknowledgement (mainly for tests); code_execute’s native path resolves confinement before staging the snippet’s cleanup path so a fail-closed refusal still removes the staged file.

Acceptance proof. sandbox::native::tests::native_deny_all_egress_is_enforced_or_ fails_closed (Linux-only): on a host with unprivileged-netns support, a benign command still succeeds inside the namespace and ip route show default inside it is empty (no route out — direct evidence of the floor, not just an assumption); on a host without support, the same deny-all request fails closed. The reciprocal native_deny_all_egress_fails_closed_without_a_floor runs on every non-Linux platform. At the tool layer, shell_fails_closed_on_unsandboxed_native_without_acknowledgement/ shell_runs_native_when_unsandboxed_run_is_acknowledged/ native_only_shell_never_fails_closed_for_missing_acknowledgement/ code_execute_fails_closed_on_unsandboxed_native_without_acknowledgement all pass on any platform by branching on the same network_isolation_available() probe the production code uses — proven live on this Windows dev host (no floor available: the unacknowledged tests observe the fail-closed path; native_only()/acknowledged tests observe the unsandboxed-but-logged path).

Claim scoping. docs/07-tool-runtime/security-isolation.md gained §5.1 stating precisely where “sandboxed” holds for the native backend (Linux egress floor; Windows/macOS explicit-acknowledgement-or-refusal; filesystem confinement remains a named gap on every platform). DISTRIBUTION.md’s “use everywhere” positioning line now reads “sandboxed tool execution (container/gVisor isolation for untrusted code, a native egress floor for trusted runs)” instead of the unqualified “sandboxed tools”. ShellTool/CodeExecuteTool/NativeSandbox’s own doc comments carry the same scoping. Scope note (PRD-007 §4.2 non-goal, confirmed as intended, not deferred): no cross-platform parity to the Linux+Docker container path was built for the native backend — a Windows/macOS native run’s only isolation is the acknowledgement-or-refusal gate, and native filesystem confinement beyond workdir was not added on any platform; an untrusted or filesystem-sensitive run should select the container/gVisor backend via trust classification (§3) instead.

QA-401 [P1] — Wire the “proven” tests into a CI job — DONE

Section titled “QA-401 [P1] — Wire the “proven” tests into a CI job — DONE”

Problem. CI’s services-integration job runs exactly 6 gated targets and fails-on-skip, but several “proven”/“closed” acceptance tests are invoked by no job: wovyr-server/src/tenancy.rs redis_tests (SRV-307 fleet-shared quota, including the literal acceptance test at tenancy.rs:2170), wovyr-tools/tests/sandbox_backends.rs (17 tests), and wovyr-tools/tests/egress_proxy.rs/egress_adversarial.rs (the L3-lockdown claim). They silently skip everywhere. (PRD-007 finding 12; audit High.)

Change. Add these targets to the services-integration job’s explicit list (it already provisions Docker/Postgres/Redis and fails-on-skip), so a regression in shared-concurrency, sandbox isolation, or egress lockdown can no longer ship green. Where a target needs a capability CI lacks (Firecracker kernel/rootfs), document it as an explicit, named exclusion rather than a silent skip.

Acceptance criteria.

  • The services-integration job runs the tenancy-redis, sandbox-backend, and egress targets, and fails if any reports skipping:.
  • Any genuinely un-CI-able target is listed in the job with a comment naming why.

Files. .github/workflows/ci.yml. Size. S. Depends on: none.

Done (2026-07-25). services-integration gained a tenancy::redis_tests run right alongside the existing rate_limit redis test (same job, same already-running Redis service container, same run_gated fail-on-skip wrapper) — closing the SRV-307 fleet-shared-concurrency acceptance test’s CI gap.

The sandbox-backend suite (wovyr-tools/tests/sandbox_backends.rs, 17 tests) went into a new, separate sandbox-integration job rather than into services-integration: making the 3 gVisor tests real requires installing gVisor’s runsc and restarting the Docker daemon so the registration takes effect, and services-integration’s Postgres/Redis/Qdrant services: containers are already running before any step in that job executes — a mid-job Docker restart there would kill them and cascade-fail unrelated tests. The new job installs runsc via gVisor’s official Docker-runtime quickstart (ptrace platform, no KVM needed — GitHub- hosted runners support it), verifies the install with a named, fail-loud step (docker info | grep -qi runsc) rather than letting a broken registration surface only as a buried skipping: line deep in the test output, then runs the suite with --skip firecracker and fails on any remaining skipping: line. Firecracker’s 2 tests are the one documented, permanent exclusion, exactly as the ticket anticipated: GitHub-hosted runners have no nested KVM, so a real microVM can never run there. This also covers the L3-egress-lockdown adversarial test (container_egress_lockdown_blocks_direct_bypass_of_the_proxy) embedded in the same file, since it’s one of the 15 non-Firecracker tests.

Correction to the ticket’s premise. egress_proxy.rs/egress_adversarial.rs needed no wiring: re-reading both files during this ticket, neither is capability-gated at all (no Docker/runsc dependency — egress_adversarial.rs’s own doc comment states this explicitly: “Unlike sandbox_backends.rs, these need no docker/runsc… They run unconditionally in CI”). Both already ran, and still run, as ordinary tests inside the rust job’s plain cargo test --workspace step — there was no skip to close for these two files specifically. The audit finding’s “silently skip everywhere” framing held for the Docker-gated egress-lockdown test inside sandbox_backends.rs, which the sandbox-integration job above now covers.

A structural bug found and fixed along the way: sandbox_backends.rs had no feature gate at all. Before this ticket, the file compiled and ran unconditionally as part of cargo test --workspace in the plain rust job — the runtime has(SandboxBackend::…) capability check inside each test only skips when a backend is genuinely absent, but ubuntu-latest ships Docker by default, so the Container-backed tests actually executed for real there, every push, with no dedicated job ever having asked them to. This was discovered empirically, not by inspection: this session pushed SEC-404 (commit 5d17619) and then checked this repo’s live GitHub Actions history via the public API (job logs need admin rights this session didn’t have, but per-job step lists, timings, and check-run annotations are readable anonymously). The rust job’s Test step failed in ~1m37s (exit 101) on every one of the last 8+ runs, including 5d17619 — fmt/clippy/build all passed; only cargo test --workspace failed, and the annotation named the exact command: cargo test -p wovyr-tools --test sandbox_backends. Listing that run’s failures showed 13 passed, 4 failed:

  • shell_tool_first_party_run_stays_native_even_when_containers_exist — a real regression from SEC-404, not a pre-existing issue: this test constructs its ShellTool via with_manager(...) (no acknowledgement) and asserts a first-party run stays native. SEC-404’s new confinement gate makes an unacknowledged with_manager() native run fail closed on a host with no netns egress floor — exactly the intended behavior for a hosted deployment, but this test predates that gate and never opted in. Fixed: added .with_unsandboxed_native_ack(true) — the test is about backend selection (native vs. container), not about confinement enforcement, which SEC-404’s own tests already cover.
  • container_egress_lockdown_blocks_direct_bypass_of_the_proxy, container_pids_limit_contains_a_fork_bomb, egress_proxy_denies_non_allowlisted_host_from_container — confirmed pre-existing via git log on each file (sandbox_backends.rs, container.rs, egress.rs/egress_lockdown.rs): none touched by any commit in this milestone, last changed in b1105e8/19b5169/146af46, well before RM-AR-P1 started. All three use the plain Container backend (not gVisor), so installing gVisor does not touch whatever is actually failing — a real, currently-unexplained gap in this runner’s Docker/cgroups/iptables behavior for pids-limit enforcement, host-side egress lockdown, and in-container proxy denial. Explicitly not patched blind (no live Docker host available in this session to diagnose against) and explicitly not silenced with #[ignore] (a deliberate product decision, confirmed with the user): the whole point of landing this job is that these specific security-relevant behaviors don’t currently hold here, and that should be visible, not hidden behind a green check. Filed as its own tracked follow-up task rather than folded into this ticket’s scope.

Structural fix: sandbox_backends.rs is now gated behind a new, dependency-free sandbox-integration-tests cargo feature (wovyr-tools/Cargo.toml) — #![cfg(feature = "sandbox-integration-tests")] at the file’s crate-level attribute — so plain cargo test --workspace (the rust job, any contributor’s machine) compiles zero tests from this file (verified: running 0 tests); only the sandbox-integration job’s explicit --features sandbox-integration-tests build compiles and runs the real 17 (verified: -- --list shows all 17; run offline with the feature, all 17 skip cleanly with no Docker present, 0 failed).

Validated: the new/changed YAML was parsed end-to-end with js-yaml (all 12 jobs present, sandbox-integration’s steps structurally correct) both before and after the corrections above. cargo clippy/cargo test were run locally for wovyr-tools in both feature states (with and without sandbox-integration-tests) and for the whole workspace — all clean. This session’s dev environment has no Docker/gVisor to locally execute the job’s Container/gVisor tests themselves, so whether the gVisor install script succeeds and whether the 14 non-excluded, non-pre-broken tests pass against live infrastructure is proven by this PR’s own CI run, not by local execution beforehand; a broken gVisor install surfaces as a clearly-named, isolated step failure rather than a silent skip or a cascade into unrelated services.

QA-402 [P2] — Coverage floor gate — DONE

Section titled “QA-402 [P2] — Coverage floor gate — DONE”

Problem. The coverage job runs cargo llvm-cov and uploads an artifact but enforces no threshold (.github/workflows/ci.yml, no --fail-under). Coverage is decorative; a PR can drop it to any level and pass. (PRD-007 finding 13; audit Medium.)

Change. Add a --fail-under threshold set at (or just below) today’s measured line coverage, so coverage cannot silently regress. Document the number and how to raise it.

Acceptance criteria.

  • The coverage job fails when coverage drops below the floor (verify by temporarily setting the floor above current and confirming the job fails).

Files. .github/workflows/ci.yml. Size. S. Depends on: none.

Done (2026-07-25). A new Enforce coverage floor (QA-402) step runs cargo llvm-cov report --fail-under-lines "$COVERAGE_FLOOR_LINES" after the existing measurement step — re-deriving the check from the already-collected profile data (no recompilation, no test re-run) — as its own clearly-named step so a coverage regression reads as a distinct finding rather than being buried inside the measurement step. The Upload coverage report step gained if: always() so the lcov/summary artifact is still published even when the floor check fails, giving a maintainer the actual numbers to act on rather than just a red X.

The floor (COVERAGE_FLOOR_LINES=45) is a documented placeholder, not a real measurement of “today’s coverage” as the ticket’s literal text asks for — this job has never once completed successfully in this repo’s CI history. Checked via the GitHub API across the last 8+ runs on main (including the one testing this milestone’s own SEC-404 commit): the coverage job failed on every single one. Investigating QA-401 in this same session found the almost-certain root cause: wovyr-tools/tests/sandbox_backends.rs had no feature gate, so cargo llvm-cov --workspace (which drives the workspace’s tests exactly like cargo test --workspace, just instrumented) ran it unconditionally too, hitting the same 3 pre-existing, environment-specific Docker-backend failures documented under QA-401’s sandbox-integration entry — very likely failing this job’s test run before it ever reached a coverage percentage. That file is now gated behind sandbox-integration-tests (off by default), so this job no longer touches it either — the coverage job should very plausibly succeed for the first time once this lands. This session’s dev environment has no way to produce a real coverage number itself: the local GNU Rust toolchain lacks profiler_builtins (error[E0463]: can't find crate for profiler_builtins — a known gap for instrument-coverage on windows-gnu targets), and the locally-installed MSVC toolchain has no working link.exe on PATH. COVERAGE_FLOOR_LINES=45 is therefore a deliberately conservative placeholder — chosen to sit comfortably below what an extensively-tested ~79k-LOC codebase like this one almost certainly measures, while still catching a catastrophic regression (most of the test suite deleted or disabled) — not the ticket’s originally-intended “just below today’s measured number.” Once this job completes successfully for the first time, its real percentage should replace this placeholder with a tighter floor, per the ticket’s original intent.

QA-403 [P2] — Make “verified live” reproducible or reclassify — DONE

Section titled “QA-403 [P2] — Make “verified live” reproducible or reclassify — DONE”

Problem. ~11 “verified/proven live” claims have no reproducible test: S3 backup (CLAUDE.md admits “not validated against a live S3-compatible endpoint”), Postgres TLS (sslmode=require never exercised — CI uses plaintext), cross-process CLI↔server KMS sharing, and browser e2e. These are one-time manual checks that rot silently. (PRD-007 finding 14; audit Medium.)

Change.

  • Add a MinIO service container and run the S3 SigV4 backup→restore round trip against it (also the acceptance leg for STR-504).
  • Add a TLS-enabled Postgres leg exercising the resolve_tls_mode / sslmode=require path.
  • Add a cross-process test that seals via the CLI binary and reads back via a separately-started server (or vice versa).
  • For any claim that genuinely cannot be automated in this environment, downgrade its wording in CLAUDE.md/docs from “verified live” to “manually spot-checked, not CI-gated.”

Acceptance criteria.

  • S3 round trip and Postgres-TLS legs run in CI and pass.
  • No doc contains a “verified live” claim that lacks either a CI test or an explicit “not CI-gated” qualifier.

Files. .github/workflows/ci.yml, apps/wovyr-cli/src/s3.rs (test hooks), crates/wovyr-workflow/tests/, CLAUDE.md. Size. M. Depends on: none. Feeds: STR-504.

Done (2026-07-25).

S3 round trip. services-integration gained a minio service (the fclairamb/minio-github-actions wrapper image — a bare minio/minio image can’t be used as a GitHub Actions services: container since that mechanism has no way to pass it the server /data command it needs; bitnami/minio is the common alternative but has documented startup flakiness there). No credentials are baked into the image; it falls back to MinIO’s own long-standing minioadmin/ minioadmin default, which a new “Wait for MinIO and create the test bucket” step points the official mc client at to provision the bucket this client needs pre-existing (matching production scope — an operator provisions the bucket, wovyr admin backup doesn’t). admin.rs’s backup_cmd/restore_cmd were refactored into parameterized run_backup/run_restore cores (mirroring backup_dir/restore_dir’s existing local-path parameterization) so a test can drive the real s3:// upload/download path against a scratch local directory without mutating the process-global HOME/USERPROFILE config::config_dir reads. The new test admin::tests::s3_backup_restore_round_trips_against_a_live_endpoint — gated on WOVYR_S3_ENDPOINT being set, skipping cleanly offline — round-trips two files through a real MinIO endpoint via run_backup/run_restore and asserts the downloaded bytes match. services-integration runs it via run_gated.

Postgres TLS. A plain services: Postgres container can’t be handed a custom cert/key mount (services start before the repo is even checked out), so a second Postgres is started as an ordinary docker run step instead (port 5433) with a self-signed cert generated on the fly via openssl reqchmod 600 + chown 999:999 (the official image’s runtime uid/gid) since Postgres refuses to start with a group/world-readable private key. The new crates/wovyr-workflow/tests/postgres_store.rs::connects_over_a_real_tls_ handshake_with_sslmode_require test — gated on a distinct WOVYR_WORKFLOW_POSTGRES_TLS_URL env var (sslmode=require already in the URL, alongside the existing plaintext WOVYR_WORKFLOW_POSTGRES_URL) — asserts the URL actually requests TLS, connects via PostgresStore::connect (which resolves resolve_tls_moderustls_connector for real, not just the pure connection-string-parsing unit tests already in postgres.rs), and proves the connection is genuinely usable (not just established) via a real append+load round trip. The TLS instance’s schema is migrated the same way as the plaintext one (run_migrations resolves TLS through the identical resolve_tls_mode path connect uses).

Cross-process CLI↔server KMS sharing. New apps/wovyr-cli/tests/cross_process_kms.rs — not capability-gated on any external service, so it runs unconditionally in the plain rust job — spawns the real wovyr binary (via CARGO_BIN_EXE_wovyr, since wovyr-cli is bin-only with no lib target a tests/ file could otherwise link against) twice against a shared scratch HOME: once to memory put --sensitive (the CLI’s local path, generating a fresh KMS root key on first use), once as wovyr dev (a separate server process, WOVYR_ALLOW_ANONYMOUS=1 + WOVYR_PLATFORM_ADMINS for the memory:read scope its own records route needs). The sealed record is read back over the server’s HTTP API and the plaintext content must round-trip — proving the claim for real rather than by one-time manual check. Verified live in this very session: run twice locally on this Windows dev box, passing both times (~2.6–5s each) — the first fully-automated proof this claim has ever had.

Sensitive-memory ciphertext-on-disk/plaintext-in-API claim. Not explicitly named in the ticket’s problem statement but found during the “verified live” sweep below and fixed the same way: new wovyr-server/src/tests.rs::sensitive_memory_record_is_ciphertext_on_disk_and_ plaintext_over_the_api writes a sensitive + non-sensitive record into the same namespace over a real FileStore + EncryptingMemoryStore, asserts the sensitive content is absent from the raw .jsonl while the non-sensitive one stays plaintext there, and asserts both come back in plaintext through list and hybrid-query API reads.

The “verified live” wording sweep. All 9 instances of “verified live”/ “proven live” found in CLAUDE.md were resolved: 3 now point at real, newly-added automated tests (the two above, plus the CI-gated contract-gate redocly-lint check, whose wording was reworded to name the job rather than say “verified live”); 1 already had real automated coverage that just needed the phrasing corrected (kms_rotate_is_routine_but_destroy_needs_a_higher_tier, covering the post-destroy 403); the remaining 5 (mistral.rs live-model example, two MCP-connection/dashboard browser checks, one EMB-701 live-server check beyond its own 147 passing tests, one ITS-601/602 Surfaces-panel browser check) were downgraded to the ticket’s prescribed “manually spot-checked, not CI-gated” wording, since no browser e2e harness exists in this workspace and the mistral.rs path is deliberately excluded from CI (real network + CPU inference time neither is guaranteed there). Scope note: this was a thorough, not exhaustive, sweep — it covered every literal “verified live”/ “proven live” match in CLAUDE.md; other documents (README, DISTRIBUTION.md, docs/) were not re-swept in this pass.

Validated: cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all --check both clean after every change above; the three new tests (S3, Postgres TLS test file, cross-process KMS) all compile and skip cleanly offline on this Windows dev host (no live MinIO/TLS-Postgres here); the cross-process KMS test needed no live external service and was run for real, twice, passing both times. The new wovyr-server sensitive-memory test was also run for real (no external dependency beyond the in-process KMS/FileStore) and passes. Whether the MinIO and Postgres-TLS legs pass against the actual CI service containers is proven by this PR’s own CI run — this session’s environment has no Docker to stand either up locally.

STR-501 [P1] — Version / maturity reconciliation — PLANNED

Section titled “STR-501 [P1] — Version / maturity reconciliation — PLANNED”

Problem. The workspace ships version = "0.3.0" (Cargo.toml:31) and /healthz returns 0.3.0, while the roadmap/README narrate “v1.0 GA / v1.1 / v1.2 / v1.3 shipped.” A buyer sees pre-1.0; the story says enterprise-GA. (PRD-007 finding 16; audit Medium.)

Change. Pick one source of truth. Either bump the workspace version to match the milestone narrative (and this milestone becomes the real 1.x line), or restate the narrative to 0.x maturity — in lockstep across every manifest, the README badge, CHANGELOG.md, and /healthz, per the DX-101 reconciliation process. Recommended: align the version up to the narrative once P1/P2 land, so “GA” and the version agree only after the day-one findings are fixed.

Acceptance criteria.

  • Cargo.toml, all crate manifests, README badge, /healthz, and CHANGELOG.md report one consistent version; a test/CI check asserts they match (extend the DX-101 lockstep check).

Files. Cargo.toml + crate manifests, README.md, CHANGELOG.md, crates/wovyr-server (/healthz), .github/workflows. Size. S. Depends on: P1/P2 landing (so the version claim is honest).

STR-502 [P1] — Claim-honesty pass — PLANNED

Section titled “STR-502 [P1] — Claim-honesty pass — PLANNED”

Problem. README.md, CLAUDE.md, and DISTRIBUTION.md overstate two headline claims (“tamper-evident audit log”, “sandboxed tools”) and carry three divergent positioning one-liners; several AI features are described without their “only with a real embedding provider” / “Anthropic-only breaks this” caveats. (PRD-007 findings 3, 4, 9, 16; audit cross-cutting.)

Change. A single sweep reconciling every security/AI claim with the post-SEC/AIC reality: “tamper-evident” and “sandboxed” scoped precisely (or upgraded, per SEC-403/404), the embedding/default-RAG caveats stated, the hand-rolled/unvalidated code flagged, and the three positioning one-liners consolidated into one.

Acceptance criteria.

  • A checklist (kept in this doc or an appendix) of every headline claim, each marked matches-code or scoped-to-match; zero unqualified overstatements remain.
  • One positioning line used consistently across README/DISTRIBUTION/CLAUDE.

Files. README.md, CLAUDE.md, DISTRIBUTION.md. Size. S. Depends on: SEC-403, SEC-404, AIC-301, AIC-303 (so the reconciled wording matches shipped behavior).


Theme: AI-quality polish plus the sustainability boundary the audit says the project most needs.

AIC-303 [P2] — Default to keyword retrieval without a real embedder — PLANNED

Section titled “AIC-303 [P2] — Default to keyword retrieval without a real embedder — PLANNED”

Problem. The default strategy is Hybrid (crates/wovyr-memory/src/record.rs:139-140), RRF-fusing BM25 with vector ranks (engine.rs:574-585). With the default mock provider the vector half is non-semantic noise, so hybrid RRF is worse than keyword-only; MMR (engine.rs:852-874) is likewise meaningless offline. Tests pass only because fixtures are keyword-dominated. (PRD-007 finding 9; audit Medium-High.)

Change. When the resolved provider cannot produce semantic embeddings (mock/ none), default the retrieval strategy to keyword/BM25 and disable MMR; re-enable hybrid + MMR automatically when a real embedding provider is present. Log the chosen mode.

Acceptance criteria.

  • A test with the mock provider asserts default retrieval uses keyword ranking and its precision on the audit’s fixtures is ≥ the hybrid path’s.
  • With a real embedding provider, hybrid + MMR engage (existing tests pass).

Files. crates/wovyr-memory/src/{record.rs,engine.rs}. Size. S. Depends on: AIC-301 (embedding-provider resolution).

AIC-304 [P2] — Multimodal-aware token counting — PLANNED

Section titled “AIC-304 [P2] — Multimodal-aware token counting — PLANNED”

Problem. TokenCounter::count_message (crates/wovyr-provider/src/tokenizer.rs:37-49) sums content + tool-call text but never reads msg.parts, so a base64 image counts as ~4 tokens. context::compact then believes a multimodal prompt is tiny and never trims it — defeating the compactor for exactly the payloads that threaten the window. (PRD-007 finding 10; audit Medium.)

Change. Extend count_message to estimate parts (image/audio) token cost — a documented per-part heuristic (provider image-token tables where known, a size-based estimate otherwise), same “budgeting-not-billing” stance as the existing heuristic tokenizer.

Acceptance criteria.

  • A test asserts a message carrying an image part counts materially more than its text alone, and that compact trims a multimodal history that exceeds the budget.

Files. crates/wovyr-provider/src/tokenizer.rs, crates/wovyr-agent/src/context.rs (if needed). Size. S. Depends on: none.

AIC-305 [P2] — OpenAI reasoning-model parameter compatibility — PLANNED

Section titled “AIC-305 [P2] — OpenAI reasoning-model parameter compatibility — PLANNED”

Problem. The OpenAI adapter sends temperature (crates/wovyr-provider/src/openai.rs:86-88) and max_tokens (:89-91) unconditionally; o1/o3-class models reject max_tokens (require max_completion_tokens) and non-1.0 temperature. Pinning a reasoning model is a hard 400. (PRD-007 finding 10; audit Medium.)

Change. Detect reasoning-class models (by id prefix / a capability flag) and emit max_completion_tokens instead of max_tokens, and omit non-default temperature. Keep the current behavior for standard models.

Acceptance criteria.

  • A recorded-fixture / unit test asserts a reasoning-model request serializes max_completion_tokens and no non-1.0 temperature; a standard-model request is unchanged.

Files. crates/wovyr-provider/src/openai.rs. Size. S. Depends on: none.

WFL-309 [P2] — Validate ${...} references against DAG edges — PLANNED

Section titled “WFL-309 [P2] — Validate ${...} references against DAG edges — PLANNED”

Problem. template.rs’s lookup returns Value::Null for any unresolved path (crates/wovyr-workflow/src/template.rs:70-77) with no error; scheduling readiness is computed purely from graph transitions (engine.rs:882-904), not data references. So an activity B whose inputs reference ${A.output} with no edge A→B can batch/schedule before A and silently bind null. A whole class of authoring mistakes becomes silent wrong data. (PRD-007 finding 11; audit Medium.)

Change. At Definition::from_yaml, cross-check every ${activity.field} reference against the DAG: the referenced activity must exist and there must be an edge ordering it before the referrer. An unresolved reference at runtime is an Error, not a silent null.

Acceptance criteria.

  • A definition-load test rejects a manifest whose activity references a non-predecessor’s output (fails closed at load).
  • A runtime test asserts an unresolved reference errors rather than binding null.
  • Existing valid workflows (research-team, for_each examples) still load and run.

Files. crates/wovyr-workflow/src/{definition.rs,template.rs}. Size. M. Depends on: none.

STR-503 [P2] — Subsystem freeze + experimental labeling — PLANNED

Section titled “STR-503 [P2] — Subsystem freeze + experimental labeling — PLANNED”

Problem. ~20 product-grade subsystems / 23 packages / ~79k Rust LOC under a bus factor of 1, with 37 backend impls × 5 sandbox tiers × ~24 features — an untestable, unownable matrix; several backends exercise only in a service-container CI job. (PRD-007 findings 15, 17; audit High.)

Change.

  • Freeze the subsystem count for this milestone — no new subsystem without an explicit decision recorded here.
  • Label the heavy/unwired or infra-dependent backends experimental in docs and in each gated feature’s own doc comment: Firecracker/microVM, mistral.rs, the Qdrant/Postgres tiers, the plugin marketplace. The supported core (single binary + file stores + native/WASI/Linux-container sandbox) is stated unambiguously.
  • Record bus-factor-1 as an accepted, documented strategic risk (with the “vendor the security-critical hand-rolled code” mitigation → STR-504).

Acceptance criteria.

  • A “supported vs experimental” matrix exists in the README/docs; every gated feature’s doc comment states its tier.
  • No new workspace member added during the milestone.

Files. README.md, CLAUDE.md, per-crate feature doc comments, docs/12-deployment/*. Size. M. Depends on: none.

STR-504 [P2] — De-risk hand-rolled security code — PLANNED

Section titled “STR-504 [P2] — De-risk hand-rolled security code — PLANNED”

Problem. A 694-LOC hand-rolled SigV4 signer (apps/wovyr-cli/src/s3.rs, admitted unvalidated against a live endpoint), a hand-rolled Postgres pool (crates/wovyr-workflow/src/postgres.rs), and the egress firewall — the code easiest to get subtly wrong and hardest for one person to keep correct. (PRD-007 finding 18; audit High.)

Change. For SigV4: replace with a vendored, maintained crate or gate the S3 path behind the QA-403 MinIO round trip and an explicit “experimental” label (STR-503) until validated. Document the hand-rolled Postgres pool’s tested envelope and reconnect semantics; if a maintained offline-vendorable pool exists, prefer it.

Acceptance criteria.

  • The S3 path is either backed by a vendored signer or passes the QA-403 MinIO round trip in CI and is labeled experimental until then.
  • The Postgres pool’s behavior under connection loss is covered by a test and its envelope documented.

Files. apps/wovyr-cli/src/s3.rs, crates/wovyr-workflow/src/postgres.rs, docs. Size. M. Depends on: QA-403 (MinIO leg).

STR-505 [P3] — Wire-or-cut the built-but-unwired features — PLANNED

Section titled “STR-505 [P3] — Wire-or-cut the built-but-unwired features — PLANNED”

Problem. Reranking, MMR, guardrails, the prompt registry, multimodal parts, and structured output are all “engine-level only, not surfaced in server/CLI” — maintained code delivering zero user value today (over-engineering by definition). (PRD-007 finding 19; audit Medium.)

Change. For each feature, make and record an explicit decision: wire it (add the server/CLI/manifest surface that lets a user reach it) or fence it (mark experimental per STR-503, or remove if it has no near-term consumer). No feature remains silently unreachable-but-maintained.

Acceptance criteria.

  • A table in this doc lists each of the six features with a wire/fence/remove decision and, for “wire,” the surface added.
  • No engine feature is described in CLAUDE.md as “not surfaced yet” without a corresponding wire/fence/remove decision here.

Files. varies per decision (crates/wovyr-server, apps/wovyr-cli, crates/wovyr-agent manifest schema), CLAUDE.md. Size. M–L (depends on how many are wired vs fenced). Depends on: STR-503.


  1. Every Phase-1 day-one finding (SEC-401/402/405/406, AIC-301/302) is fixed with a regression test that fails against pre-fix code.
  2. The two overstated claims are reconciled — SEC-403 makes verify() catch truncation and interior edits; SEC-404 either adds a native floor or scopes the “sandboxed” claim precisely; STR-502 leaves zero unqualified overstatements.
  3. The default config works — Anthropic-only fails loud at startup (AIC-301); default retrieval ≥ keyword-only (AIC-303); caches are bounded (AIC-302).
  4. “Proven” == CI — QA-401 runs the previously-unrun acceptance tests; QA-402 enforces a coverage floor; QA-403 makes the live claims reproducible or honestly reclassified.
  5. The scope line is drawn — STR-501 gives one version; STR-503 freezes and labels the subsystem set; STR-504 vendors-or-fences the hand-rolled security code; STR-505 wires-or-cuts every unwired feature.

VersionDateDescription
1.0.02026-07-23Initial v1.4 tickets executing PRD-007: Phase 1 (stop the bleeding — SSRF redirect/ranges, cross-tenant authz, KMS fail-closed, Anthropic embedding fail-loud, bounded caches), Phase 2 (make claims true — audit MAC, sandbox scoping, CI truth, version/claim reconciliation), Phase 3 (quality & sustainability — default retrieval, multimodal tokens, reasoning params, workflow ref validation, subsystem freeze, hand-rolled-code de-risk, wire-or-cut)