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
1. Purpose
Section titled “1. Purpose”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.
2. Sequencing at a glance
Section titled “2. Sequencing at a glance”── 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-sourcingSEC-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 legSTR-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.
3. Phase 1 — Stop the Bleeding
Section titled “3. Phase 1 — Stop the Bleeding”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 MCPHttpconnect 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
OrgAdminonly in tenant A receives403onGET /api/v1/organizationsandPOST /api/v1/organizationswithX-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=1path 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.
4. Phase 2 — Make Claims True
Section titled “4. Phase 2 — Make Claims True”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 mutateaudit.jsonldirectly 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 FirstParty → SandboxBackend::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_executewhere the host supports it (e.g. deny-by-default egress and a workdir-scoped filesystem view where an OS mechanism exists), reusing the existingNetworkPolicytype 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, andDISTRIBUTION.mdto exactly where it holds (feeds STR-502).
Acceptance criteria.
- A test asserts native
shellcannot 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 run — native_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-integrationjob runs the tenancy-redis, sandbox-backend, and egress targets, and fails if any reportsskipping:. - 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 itsShellToolviawith_manager(...)(no acknowledgement) and asserts a first-party run stays native. SEC-404’s new confinement gate makes an unacknowledgedwith_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 viagit logon each file (sandbox_backends.rs,container.rs,egress.rs/egress_lockdown.rs): none touched by any commit in this milestone, last changed inb1105e8/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=requirepath. - 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 req — chmod 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_mode → rustls_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, andCHANGELOG.mdreport 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).
5. Phase 3 — Quality & Sustainability
Section titled “5. Phase 3 — Quality & Sustainability”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
compacttrims 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_tokensand no non-1.0temperature; 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.
6. Exit Criteria (milestone)
Section titled “6. Exit Criteria (milestone)”- 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.
- 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. - The default config works — Anthropic-only fails loud at startup (AIC-301); default retrieval ≥ keyword-only (AIC-303); caches are bounded (AIC-302).
- “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.
- 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.
7. Revision History
Section titled “7. Revision History”| Version | Date | Description |
|---|---|---|
| 1.0.0 | 2026-07-23 | Initial 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) |