# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **Cadence:** weekly rolling builds are cut from `main`; versioned releases > ship every 4–8 weeks. Every versioned release gets human-readable notes > here — if you tag a release, you write its entry. ## [Unreleased] ### Added - **Jev setup is checkable and configurable.** `crux-jev doctor` checks a Jev integration end to end without spending a Jev call: `crux-jev`'s configuration, the two daemon flags, the token's read scopes (naming the `dev_scopes` literal-token trap when they fail), the published signing key and the pinned keyring, each failure with its fix. The daemon reads `daemon.stream_receipts` and `daemon.context_surface` from `config.yaml` (the environment still wins), and `GET /v1/version` reports `capabilities.stream_receipts`. - **`crux-jev verify --tenant` / `--actor`.** The signed body must name the tenant (default `local`) and, when asked, the minting passport; the result reports `tenant_id` and `actor_passport`. - **Crux Claude Code mod in setup.** `corecruxctl login` / `start` and `crux-config-wizard init` now install `crux-desktop`, a Claude Code function-hook mod that owns session continuity: a context block on the first prompt, `save_session` (as `hook:session:`) after every turn, before compaction and at session end, a status-line entry and a `/crux` pane. The installer writes the mod to `~/.claude/mods/crux-desktop`, adds it to `CLAUDE_CODE_PLUGIN_DIRS` and `pluginConfigs` in `~/.claude/settings.json`, and registers the daemon as a user-scope `crux` MCP server in `~/.claude.json` (an existing server of that name is left alone). With the mod installed, `hooks install` no longer wires the SessionStart banner or the PreCompact save. New `corecruxctl mods install|uninstall|status` (and the same under `crux-config-wizard mods`); opt out with `--no-mod`. - **`corecruxctl receipts verify-stream-receipt`** verifies a daemon stream receipt (`model_invocation`, `context_injected`, `stream_completed`, `stream_aborted`) offline from its observation record against a pinned public keyring, checking the daemon's own signature over the canonical body. It rejects other receipt classes signed by the same key (e.g. `usage_ping`) on the signed `kind`, takes optional `--expect-receipt-id` / `--kind` checks, and on success prints only fields decoded from the signed body — the record's `payload.output_hash` and similar copies are unsigned. Library entry point: `corecrux_receipts::verify_stream_receipt_payload_v1`. - **`GET /v1/receipts/signing-keys`** serves the daemon's receipt-signing public key, unauthenticated, as an `Ed25519KeyRingV1` that `verify-stream-receipt --keyring` reads unchanged (`keyId` is the passport fingerprint receipts carry; also `publicKeyHex`, `alg`, `use`). Verifiers no longer need `admin:read` on `/v1/admin/version`. Key discovery, not trust: pin the key at enrolment. - `GET /v1/observations/aggregate` takes `chains=returned|all|none`. - Session metrics `vaultcrux_session_capability_graph_edges{origin,tier}` and `vaultcrux_session_excluded_count{origin,reason}` for issued session plans. (#813) ### Changed - **`crux-jev` fixes.** - `pin-key` reads the `publicKeyHex` the signing-keys route actually serves (it looked for `public_key_hex`), needs no token, and writes the keyring atomically. - `verify` checks every decision fact naming a receipt, so a later edited version can no longer pass on the strength of an earlier good one. - A request with `store_state: "false"` (a string, which is truthy) or an unknown `state_layout` is refused with exit 2 instead of being coerced or failing as exit 1. - A Jev reply with no answers raises a named error rather than `KeyError`. - **Jev docs match the code.** The cookbook no longer claims a receipt proves *when* a decision was made offline (the body's timestamps are caller-supplied), documents the `dev_scopes` token and the `admin:*` scopes in the default agent-token set, the `verify`/`pin-key` exit codes, and drops the stale "receipt ids are not unique" and "no offline verifier" text. - **Claude Code setup no longer reaches for private or hosted endpoints.** The `crux-coord` hook and the SessionStart banner defaulted to CueCrux's tailnet daemon (`100.70.12.73`) and to `crux.cuecrux.com` when no endpoint was configured; both now default to the loopback daemon, as the hook launcher does, and `crux-coord` reads the endpoint `login` saved. The banner no longer reports "degraded" for a loopback daemon with auth off, and sends no `Authorization` header without a token. - **`workspace-cuecrux` leaves the default profile set.** `init --profiles=all` wrote CueCrux's internal endpoints into every user's CLAUDE.md; enable it with `crux-config-wizard add workspace-cuecrux`. - **Hook install works from native Windows.** Home and Claude config paths fall back to `USERPROFILE` and honour `CLAUDE_CONFIG_DIR`; hook command paths are shell-quoted and forward-slashed for Claude Code's hook shell; the `crux-hook` and `jq` probes accept `.exe`. The Python banner stack stays a Unix feature. - **`corecruxctl start`'s summary names the registered MCP endpoint** rather than the HTTP URL relabelled as MCP. - **Passport-bound callers see and verify their own stream receipts.** With `CORECRUXD_AGENT_PASSPORTS=1` an agent token is confined to its own tenant, while the stream receipts it mints are signed under `local` and logged in unscoped `mediation::` sessions. `GET /v1/observations/aggregate` now lists a daemon-signed receipt kind to the passport that minted it (record `principal`), and `GET /v1/receipts/{id}/verification` lets a `receipts:read` caller verify a stream receipt whose signed `actor_passport` is its own passport, whatever `tenant_id` it asks under. Other kinds keep session scoping; every other caller gets the same answer as for a missing receipt. - **Agent tokens can verify receipts.** The default `CORECRUXD_AGENT_TOKEN_HTTP_SCOPES` set gains `receipts:read`, so a token that can mint a receipt (`sessions:write`) can also call `GET /v1/receipts/{id}/verification`. - **Aggregate `chains` is bounded.** `GET /v1/observations/aggregate` now reports chain status only for the sessions behind the returned records (it covered every session log: ~7.3 MB and ~31 s for a 10-record answer on a busy node). `?chains=all` restores the full map. - **Pro claim placements are derived from named implementation sites.** A claim now earns `daemon` or `hosted_control_plane` only by naming where it is implemented. **Behaviour change in `/v1/version`:** a claim with no site reports `unimplemented` (previously `contracted_external`), and `sync:managed_backup`, `audit:central_retention` and `control_plane:hosted` no longer report as `hosted_control_plane`. (#811) ### Fixed - **`context_custody_audit` no longer reports a false all-green.** REMEMBER is scored for the calling agent's passport (missing, revoked, or category-less → `partial`); PROVE is `strong` only once `CRUX_EXPORT_VERIFY_PUBLIC_KEY_HEX` pins the export signer; `standing_gap` names what remains. Expect PROVE to read `partial` on nodes without a pinned signer. (#807) - **MCP `get_bootstrap` honours `token_budget`.** The schema lacked it and the handler passed none, so a cold-start call with `token_budget=500` could return up to 100 full bootstrap facts. (#810) - **Concurrent observe step opens no longer share a `seq`.** The seq was minted under a read lock and stored under a later write lock, so two racing opens could both take `max + 1` and drop a step from the audit chain. (#814) ### Security - **Stream receipt ids are unique.** `POST /v1/mediation/receipts` refuses a stream-receipt draft whose `receipt_id` is already used (409 `RECEIPT_ID_CONFLICT`), and `/v1/receipts/{id}/verification` answers 409 `RECEIPT_ID_AMBIGUOUS` instead of the first match when a log holds more than one body for an id, so a later mint cannot shadow an earlier receipt. - **Receipt kinds are reserved.** `POST /v1/sessions/{sid}/observations` (and the batch route) refuse `model_invocation`, `context_injected`, `stream_completed`, `stream_aborted` and `usage_ping` with 422 `RESERVED_OBSERVATION_KIND`; only the signing path writes them, so `/v1/observations/aggregate?kind=model_invocation` no longer lists unsigned imitations. - **Promotion confirm refuses records for another tenant.** `POST /v1/sync/tenants/{tenantId}/promotions/confirm` authorized only the path tenant, then applied the caller-supplied records as sent, so a caller allowed to write tenant A could plant facts in tenant B. The whole batch is now refused (403) before anything applies when a record's entity is outside the path tenant's namespace, its tenant stamp names another tenant, or the caller's passport may not write the entity. **Behaviour change:** such batches get 403 instead of a partial apply. (#808) - **The observation aggregate feed no longer shows other passports' sessions.** `GET /v1/observations/aggregate` without `session_id` returned every passport's prompts, responses and mediation receipts to any `query:read` caller. A passport-bound caller now sees only its own sessions; an unbound operator with raw `admin:read` keeps the full view. **Behaviour change** for passport-bound callers. (#814) - **A revoked passport is refused at the sync and operator-tier gates even with `CRUX_PASSPORT_REVOCATION=0`.** `require_passport_tier` checked only the reputation tier, so with the dispatch-wide gate off a revoked passport could still `sync_push` / `sync_pull` and call operator-tier identity tools. (#806) - **`coord_announce` can no longer attribute an intent to someone else.** On an unbound session, `by_passport` was trusted as given. It must now name the caller's own passport (403 otherwise); an admin acts for another passport through the existing passport-header override. **Behaviour change:** an unbound announce naming a passport the caller doesn't hold is refused. (#812) ## [0.5.65] - 2026-09-22 ### Added - **Memory parity.** Recall applies a match floor and reports honest misses instead of padding weak hits (#791); a curated engram tier plus a memory digest is rendered into `CLAUDE.md` and `AGENTS.md`, with engrams on by default (#792); read-only native memory ingest as a projection (#787). - **Prompt-cache awareness.** A cache-invalidation ledger (#788), a monotone MCP tool surface per session so the tool list stops churning the cache (#789), and cold-context awareness (#790). The boot banner's Python renderer gained the cold-cache line and a token fallback (#793). - **Proof-carrying adaptive packs, first steps.** The `pack.conformance.v1` manifest format (#767) and shadow-corpus staging with a replay harness (#768, #769). - **Extension lifecycle seams.** Staged activation (#763), a conformance hook (#764), per-pack outcome events (#765), and atomic rollback to a pinned prior build (#766). - **Delegated embedding has one payer per daemon** (#760). - An `escrow_recovery_code` fuzz target (#758) and an enforced-by lint for load-bearing auth claims (#748). ### Changed - Four Pro claims that had no gate in the daemon were withdrawn from sale rather than advertised (#761). ### Security - Unwedged the security gates: yanked crates replaced and two live advisories resolved (#785). A failing scheduled security audit now notifies someone instead of failing silently (#786). - Dependency bumps: blake3, syn, wat, crc32fast, uuid, and CI actions (#749, #756, #771, #772, #773, #775, #776, #780, #781, #784). ### Fixed - **One tenant erasure could wedge every write on the shard.** `forget-tenants` with `reclaim: true` retires a segment together with the L0 directory run whose `run_id` equals its `segment_seq`. That pairing is not reliable: `publish_dir_run_v1` resolves a run-id collision by incrementing `run_id`, so a run describing segment *N* can be published as `run_id N+1` — and a stale `directory/` file is enough to trigger it (host `crux` carries 1083 dirrun files against 1061 segments). The displaced run survives the reclaim still naming a segment that is gone. `rebuild_directory_from_runs` treated that extent as corruption and returned `dirrun references missing segment_seq N`, which fails `ShardStorage::open` — and open is on the path of every write, while reads scan `segments/` directly and stay green. The result is the silent-500 signature this store has now hit twice, once for 44 hours: all ingest returning 500 with `/readyz` healthy, and `corecruxctl storage repair-manifest` reporting the shard *healthy* because the segment is already out of the manifest and only the directory still names it. An extent naming a retired segment is now a **miss**, not an error: the manifest is append-only and published runs are never rewritten, so a run outliving its segment is the designed state after erasure. The extent is skipped and counted, and each affected run logs `dirrun-extents-skipped` once. `corecruxctl storage repair-manifest` learned the same class, so a store already wedged by an older daemon can be recovered offline: it now reports `dangling_dir_runs` alongside `dangling_segments` and retires the runs whose named segments are *all* gone. A run that also names live segments is reported and left alone — its live extents are how those streams resolve, so dropping it would trade a wedged shard for a lossy one. (#740) - **One dense query could OOM the daemon on a large corpus.** The process-wide cache of parsed `.ccxe` companions was bounded only by corpus size — its own doc comment said "stays bounded by the corpus", which is precisely the problem — and the query path then *copied* every vector out of it to build the provider, so the whole corpus was resident twice for the length of every query. On host `crux` (~900 dense segments) a single `/v1/query/text-search` took the daemon from 2.8 GB to an OOM kill at 5.74 GB against a 5.5 GiB memcg limit, with `/readyz` green until the moment it died. The cache now carries a byte budget with LRU eviction, default 512 MiB and settable with `CORECRUXD_DENSE_CACHE_BUDGET_BYTES` (`0` restores the old unbounded behaviour). Candidate vectors are shared with the provider rather than copied, which removes the second resident copy outright; a segment larger than the whole budget is still cached rather than re-read on every access. **This is a memory bound, not a recall cap.** An evicted companion is re-read on the next query, so the candidate set, the scores and the ranking are bit-identical — the CE dense lane stays uncapped and exact per ExecPlan `dense-lane-and-extraction-upsell-2026-06-26`. A regression test asserts exactly that: a query touching more segments than the budget can hold scores identically to one with an unbounded cache. (#740) ## [0.5.64] - 2026-08-23 ### Fixed - **One truncated posting list could hang every query touching a segment.** `decode_block` signals truncation by returning `data.len()` and pushing no values; `pfordelta_decode` looped `while deltas.len() < count` without checking the call had made progress, so once the data ran out the loop could not terminate — wedging the decoding thread and, with it, every query over that segment. A `block_len` byte of zero reached the same state by another route. The declared count had the same origin problem: the first four bytes of whatever was handed in went straight into `Vec::with_capacity`, so an eleven-byte input claiming `u32::MAX` reserved about 17 GB. It is now clamped to what the remaining bytes could possibly encode, and the loop breaks when a block decodes nothing, returning what decoded cleanly. (#744) - **The console could not resolve a work gate even where the daemon declared it available.** Behind Caddy + oauth2-proxy the client `Authorization` is stripped, the proxy's verified identity headers are forwarded, and one shared bridge JWT is injected — `sub: console-bridge`, admin scopes, `tenant_id: *`, **no `passport_id`**. The identity rail was enabled and mapped the operator's login to a canonical passport, and `/v1/version` declared the `work_gate_resolution` rung `identity_header` available — yet every console Approve/Reject failed with *"a canonical passport_id claim is required for gate resolution"*, because the rail was consulted only by its own mint endpoints and never on the decision routes. That is precisely the anti-lying property the gate-oversight work asserts: the declaration said "available" while the real attempt failed. On the human-decision routes — `POST /v1/work/gate/{id}/approve|reject` and `POST /v1/passport/mint-requests/{id}/approve|reject` — a bearer carrying no canonical passport claim now consults the identity rail under **exactly** the trust rules its mint endpoint applies: rail enabled, peer trusted (loopback or a listed CIDR), identity header naming an allowlisted login, and that entry carrying `#passport_id`. The passport is then bound to the context and the rail recorded on the receipt envelope as `approver_source`. (#738) - **Signed exports could be re-signed by anyone.** Every verifier checked the artifact against the public key the artifact itself carried — a self-attestation, not a custody proof: edit an export, re-sign it with a key generated a second ago, and both `verify_bundle_v1` and `run_context_verify` returned a green `ok`. The write side had the mirror hole: `resolve_audit_export_signing_key` minted a throwaway Ed25519 key whenever it had neither a configured signing key nor a data dir, so the daemon could hand out an artifact no verifier could ever pin. The write side now **refuses**: a one-shot signer cannot be pinned and cannot tie two exports to one issuer, so it is a configuration error rather than a degraded mode. `build_bundle_v1` independently rejects `key_class = ephemeral` so a caller cannot hand-assemble one either. The enum variant survives, so bundles already in the wild still parse and verify. (#735) ## [0.5.63] - 2026-08-21 ### Fixed - **The ephemeral-fact GC no longer clones the whole fact store every hour.** `run_sweep_once` copied every fact into a `Vec` once an hour to pick sweep candidates; on a daemon whose fact journal had grown to 2.4 GB that was a ~1.5 GB allocation burst on top of a ~2.7 GB baseline, which tripped a 4 GiB container limit and OOM-killed the production daemon every ~60 minutes for more than twelve hours (restart policy hid it). The candidate scan now streams `all_facts()` under the read guard and keeps only candidate ids; selection semantics are unchanged. (#739) - **Receipt verification is bound to its tenant and chain position** (D3) so a receipt cannot verify under another tenant's chain. (#736) - **`/v1/coord/active` is scoped to the caller's tenant** (D4 residual). (#731) - **HTTP fact writes are attributed to the verified passport** rather than the asserted one. (#719) - Span capture no longer races the interest cache (#733); ExecPlan age is taken from git commit time, not checkout mtime (#717); the execplan-discipline gate names a runnable command (#718). ### Added - `text-search` expand hits hydrate to `stream_id` + content (#732). - Code-intel liveness carries an outcome dimension (#730); code-health projects debt findings into `debt:/` entities (#729); every pack mutation is attributed to the pack build behind it (#728). - The per-subject CEK registry lands on main (erasure, Audit II Tier 2 M5) (#725); the pre-deploy checklist moves behind a config-wizard skill (#727); observation instrumentation at the three sites where "empty" is a bug (#724), with curated `crux.outcome` guidance (#734). - Test: observation subject isolation is pinned (#737). ### Changed - Dependency refresh: tree-sitter 0.26.12, rcgen 0.14.9, thiserror 2.0.20, http-body-util 0.1.5, wat 1.256.0, plus GitHub Actions bumps. ## [0.5.62] - 2026-08-19 ### Fixed - **The macOS release build compiles again.** Provenance retention is deliberately disabled off Linux — it needs descriptor-anchored filesystem operations — and the two `cfg(not(target_os = "linux"))` stubs return early to do that. The early return left the rest of each function unreachable, which `-D unreachable-code` rejected, so the aarch64-apple-darwin job failed and v0.5.61 shipped without macOS binaries or provenance attestation. Nothing functional changed on any platform; the lint allowances are scoped to non-Linux, so the shipping Linux build keeps full strictness. macOS is only compiled on `v*` tags, which is why this reached a release before anyone saw it. (#722) ## [0.5.61] - 2026-08-19 ### Security - **Lending an approver identity is now refused wherever the device's own human can be named.** `bind_passport` on a device grant binds the approving admin's passport — which is correct for your own second machine, and impersonation if an admin ticks it while approving a colleague's. No mechanism can tell those apart, so rather than policing the intent the need is removed: where the identity rail can name the device's human, lending is refused and that human must arrive as themselves. A stronger rung that is configured but *unusable* refuses too, matching the ladder's no-silent-downgrade rule — a misconfigured identity rail must surface, not become a licence to lend. Where no stronger rung exists, lending remains the only way to name anyone and stays available. (#705, accepted risk R3) - **`/activate` no longer exists on daemons that cannot use it.** The device-approval page was served unconditionally, so a daemon with `CORECRUXD_DEVICE_GRANT_ENABLED` off still returned a fully-branded "Approve device login" form that could never approve anything. That is pure phishing surface with zero function: an attacker who obtains a `user_code` gets a genuine, correctly-branded, TLS-served page on the real domain to send a console admin to. Phishing is the first threat this rail's own module docs name, and their stated mitigation — the page shows the requesting client name — is weakest precisely when the page is legitimate, because it is. It now 404s with the flag named, matching the rest of the rail's disabled-⇒-404 idiom. This does not make the page non-public where the grant *is* enabled; that is a front-proxy allowlist decision, since approval itself is already `admin:write`-gated server-side. (#705) ### Added - **The board can now digest itself.** The daemon already derived the expensive parts of ExecPlan-board analysis — dependency depth, cycles, and the orchestrator edges that point the wrong way — but nothing ever read them on a cadence, so with 200+ open plans the analysis only existed when a human thought to ask. A new background pass (`CORECRUXD_CRUCIBLE=1`, default off, daily) runs that analysis and appends one `__board_digest__::` fact. It surfaces only: it never edits a plan, changes a work state, or resolves a finding. It is also fully deterministic and offline — no model call, no egress, no credentials — so the same binary runs the same pass on a laptop and on a server. When `CRUX_EXECPLANS_ROOT` is unset it declines to write rather than report an empty board, because a digest asserting nothing is open is indistinguishable from a digest of a board the daemon cannot see. (#715) - **Daemon-side enrichment can now use Claude, OpenAI, or a local model.** A completion provider with four modes (`none`, `anthropic`, `openai`, `local`). `none` is the default and builds no request at all, so the local-first path never depends on a model being configured, reachable, or paid for; `local` targets an OpenAI-compatible server on the machine and needs no credential. Anthropic and OpenAI get separate adapters rather than one shim, because they are separate protocols — different path, different auth header, a mandatory `max_tokens`, a top-level system field, and reply text in a content array rather than a choices array. Routing one through the other silently returns empty text instead of failing. `local` reuses the OpenAI adapter because Ollama, llama.cpp and vLLM all serve that shape natively. (#715) - **The number of distinct passports a work gate requires is now the operator's to set.** `CORECRUXD_WORK_GATE_APPROVERS` takes `1` or `2`, default `2` — the shipped behaviour, where a requester may not resolve their own gate. A hard ban does not produce separation of duties on a deployment with one human in it. It produces a second passport minted solely to approve one's own work — which passes the check while recording `distinct_passports` for what is really one person. That is a *worse* audit trail than admitting single-party approval, so the count is configurable and the receipt records the truth either way. The separation facts ride the **signed** `action_summary`, not the envelope (envelope fields are documented as non-authoritative, and "was this really two-party?" is exactly the question that needs tamper-evident material): ``` work::::approvers=:parties=<1|2>:sep= ``` `sep` is `distinct_passports` or `none:self_approved`. **`distinct_passports` means exactly that** — the daemon verifies two passports differ and cannot establish they belong to two people; the receipt never claims otherwise, and nothing in it says "four eyes". The `work_gate_resolution` capability also reports the policy, so a single-party deployment is visibly single-party. Anything unset, malformed, or out of range resolves to `2`: a typo must never silently relax a human-oversight boundary. (#705) ### Security - **A device-grant approver could grant scopes it did not itself hold.** `POST /v1/auth/device/approve` required `admin:write` and then issued whatever scopes the request named. The module contract already promised the issued scopes were "a concrete subset of the authenticated approver's verified grants", but nothing enforced it — and scope matching is exact, with no hierarchy, so `admin:write` does **not** imply `facts:write`. An approver holding only `admin:write` could therefore mint a device credential carrying authority it never had. The passport binding added in this release widened the impact: such a credential also carries a canonical passport, so it could resolve work gates — the Art.14 human-approval boundary itself. Approve now refuses any scope the approver does not hold, naming the offending scopes. Found by the adversarial pass; see `docs/design/gate-oversight-adversarial-2026-08-18.md`, which also records four accepted risks that are **not** refused. (#705) ### Added - **The daemon now declares whether a human can resolve a work gate on it, and by which evidence rung.** `work_gate_resolution` joins the runtime-capability descriptor on `/v1/version`, selected by a ladder over what the deployment is actually configured for — proxy-asserted identity header, device-grant vouching, or an unverified local daemon — strongest configured rung first. **A configured rung that cannot issue blocks rather than downgrading.** An identity rail with no passport-bound allowlist entry does not quietly fall through to the device grant: it reports `degraded` and names the fix. Without that rule a misconfiguration silently converts strong oversight into weak oversight while the receipt looks identical. The Gates page derives its Approve/Reject affordance from this declaration instead of hardcoding a flow, renders the daemon's `reason` verbatim when resolution is unavailable, and fails closed when the descriptor is absent — so a control that renders as actionable where the daemon would refuse is now structurally impossible rather than merely absent today. The console acquires no bearer token to do it: the client's standing invariant (same-origin credentials only) is preserved. (#705) - **The login rails can now name the human, so a paired device can resolve a work gate.** `resolve_gate_http` requires a canonical `passport_id` claim, but every issuance rail minted `passport_id: None` — so no credential obtained through `crux login` could satisfy the Art.14 human-approval boundary, and the console's Approve button was visible but inert. The rails shipped 2026-06-16; the canonical-passport requirement arrived six weeks later, and the two were never joined. - **Identity rail** — `CORECRUXD_TS_IDENTITY_ALLOWLIST` entries take an optional `#` suffix (`alice@example.com=acme:facts:write#p_alice`). `#` rather than a third colon-delimited field because scopes already contain colons. The passport is operator-controlled authority, exactly like the tenant and scopes; it is never taken from the request. - **Device grant** — `POST /v1/auth/device/approve` accepts `bind_passport`, binding **the approving admin's own** verified passport to the issued token and carrying it across refresh. Deliberately a flag, not a passport string: letting an approver name an arbitrary passport would be impersonation-by-admin and would hollow out non-self-review. `/activate` exposes it with that warning in the label. An entry or approval without a passport still issues an unbound token, and an unbound token still cannot resolve a gate — asserted in both directions, so binding on the rails cannot become a way for an unbound token to slip through. Both surfaces now report `passport_id` and a plain-language `gate_resolution` line, so an operator learns the token cannot approve at issuance rather than at the click. - **The identity rail works behind any authenticating proxy, not just Tailscale.** It was already proxy-agnostic apart from one hardcoded header name. `CORECRUXD_IDENTITY_HEADER` now names the header, defaulting to the `tailscale serve` preset so existing deployments are byte-identical; presets for oauth2-proxy, Cloudflare Access, Authelia and Authentik are documented. Every trust check is unchanged — loopback-or-listed-CIDR, fail-closed on absent peer info. **The proxy must strip-then-set the header:** a trusted CIDR proves the proxy sent the request, not that it authored the header. Where that cannot be guaranteed, use the device grant. (#705) ### Fixed - **A local-only daemon's Gates page was still empty for any tenant but `default`.** The previous entry widened the pending-gate read for *verified* credentials and deliberately kept auth-off confined to `default`, preserving the audit property that an unauthenticated reader must name a tenant rather than enumerate the queue. That keyed the decision on **auth mode**, when the property it actually protects is **reachability** — so the one posture with no online auth by design, a loopback daemon with a single operator at the keyboard, was left with exactly the symptom the original report described: an empty queue, and (because the console builds its tenant picker from this same response) no tenant to switch to. The unverified case now widens to every tenant only when the daemon's own listener is loopback-only **and** the request arrived on a direct loopback socket carrying no `Forwarded` / `X-Forwarded-For` assertion. Both halves are required: a reverse proxy on the same host makes every remote client's peer address loopback, so peer-is-loopback alone would hand a remote caller local authority. Anything reachable — proxied, or a non-loopback listener — keeps the narrow `default` view, and naming a tenant works exactly as before. `direct_local` only ever widens the unverified case: it cannot grant a tenant a verified token lacks, and it cannot rescue a missing tenant claim. (#706) - **The console Gates page rendered "queue is clear" while approvals were waiting in another tenant.** `GET /v1/work/gate/pending` resolved the caller to exactly one tenant via `resolve_authorized_tenant`, which collapses a wildcard/admin credential to `default`. Pending gates held in any other tenant — the reported case had two under `work` — were filtered out, and `/console#/trust/cx-gates`, which asked with no tenant context at all, showed the rich empty state. That hides the passport-attributed Art.14 human-oversight path exactly when it is owed. The read now answers for **every tenant the credential is authorized for** and declares that scope on the wire as `tenant_scope` (`["*"]` = all). A verified wildcard token spans every tenant; a multi-tenant token lists the tenants it owns (where the single-tenant write resolver refuses to guess); a single-tenant token is unchanged; a token with no tenant claim is still refused. Auth-off/DevScopes stay confined to `default` — those wildcards are a local-development convenience, not a proven principal, so an unauthenticated reader still has to name a tenant rather than enumerate the queue. Naming `?tenant_id=` keeps the previous, narrower behaviour and every existing denial. The Gates page carries the scope verbatim, offers an authorized-tenant selector, names each row's tenant, refuses to reuse the "queue is clear" state when the view is narrowed, and renders a failed or unauthorized read as an explicit failure instead of an empty queue. The same fix reaches the Overwatch "needs you" lane and "Withhold all", which read the same endpoint. Approve/reject are untouched: still operator-gated, canonical-passport bound, and still mint a CROWN `ad_ga_*` receipt. (#703) ## [0.5.59] - 2026-08-08 ### Fixed - **Erasing a tenant's corpus could take every subsequent write down with it.** `POST /v1/admin/forget-tenants` with `"reclaim": true` deleted each whole-tenant segment's file group but never updated the shard MANIFEST. `ShardStorage::open` opens every segment the manifest references, and local ingest opens the shard once per request, so from the first reclaim onward *every* `POST /v1/local/ingest` returned 500 — while reads, which scan the `segments/` directory rather than the manifest, carried on normally. On the affected host that combination went unnoticed for 38 hours: 17 dangling entries, all ingest dead, `/readyz` green throughout. - Reclaim now appends a `RemoveSegment` tombstone to the manifest and fsyncs it **before** unlinking anything. A crash in that window leaves files on disk under a manifest that has already forgotten them — inert, and reclaimable again — instead of files deleted with the manifest still pointing at them. - If the manifest cannot be updated, nothing is unlinked and the response says so (`reclaimed: false`, plus a `manifest_error`) rather than reporting a reclaim that did not happen. - `RemoveSegment` is a new manifest record type. Removal is expressed as an **append**, so no existing record or checksum is rewritten. Older daemons skip the unknown record and simply see the pre-repair state, which makes a downgrade safe but means an affected shard is only repaired once running 0.5.59 or later. - **`corecruxctl storage repair-manifest`** reports MANIFEST entries whose segment file is missing, and with `--apply` retires them. It works on the manifest alone rather than through `ShardStorage`, because opening the shard is precisely what a shard in this state can no longer do. - **Live `.ccxp` embedding-profile sidecars were being quarantined on every shard open.** The companion allowlist named `.ccxi` and `.ccxv` but not `.ccxp`, so each open swept every profile sidecar out of `segments/`. This failed silently in the worst direction: a segment with no `.ccxp` reads as legacy and is scored *without* the embedding-fingerprint check the sidecar exists to enforce. Recovering the files is a matter of moving them back out of `quarantine/`. ### Added - **The write path now has a health signal.** Local-ingest seal failures increment `corecrux_local_ingest_seal_failed_total`, and after 3 consecutive failures `/readyz` reports `local_ingest_seal` unhealthy with the underlying error. Consecutive rather than cumulative, so a single malformed document cannot unready a node. Tune or disable with `CORECRUXD_SEAL_FAILED_READYZ_THRESHOLD` (`0` disables); it defaults to on, because a write path that fails silently is what made the outage above last as long as it did. ## [0.5.58] - 2026-08-07 ### Fixed - **Ingest can no longer seal a corpus with no vectors and call it healthy.** `POST /v1/local/ingest` returned an ordinary `202` with `sealed: true`, a full `frame_count` and `dense_vectors: 0` whenever the embed step failed. BM25 still indexes, so the tenant looked fine while semantic retrieval was simply gone — a corpus that reads as healthy and answers worse, with nothing in the response to check. The cause was **ureq's default 10 MiB response-body cap**, hit while *reading* the embedder's reply: a 1024-dim vector is ~12.6 KB of JSON, so a batch past roughly 830 chunks overflowed it. The ceiling therefore moved with the embedding dimension, not the chunk count, which is why 749 chunks passed and 776 failed. - `EmbeddingClient::embed_batch` now issues sub-batches of at most 128 texts and concatenates in order, so how many chunks a caller sends no longer decides whether embedding succeeds. A failing sub-batch fails the whole call — never a short or partially embedded result. - The `202` carries `dense_status` (`ok` | `partial` | `skipped` | `not_configured` | `not_applicable`) and `dense_expected` beside the existing `dense_vectors`, and the daemon logs `local-ingest-dense-gap-sealed` at WARN with the segment sequence. **Assert `dense_status == "ok"`** after any ingest that expects semantic recall. `crux-ingest` asserts it for you and warns when a batch seals lexical-only. - Both fields are additive: a client that ignores them sees the response shape it saw before. - This matters beyond the response cap — *any* embedder error took the same silent path. An audit of one host found 41 of 641 segments sealed lexical-only, most of them from ordinary `502`s. - **`/v1/local/ingest` honours the request-body limit its own error advertises.** The route inherited axum's 2 MiB `DefaultBodyLimit` while its `413` named the 16 MiB daemon limit, so every client that sized batches from that number sized them eight times too large. ### Added - **`segment_seq` on query results.** Each `/v1/query/text-search` result and `/expand` chunk now carries the sealed segment's own sequence — the value the ingest receipt returned — so a consumer can join a result back to the ingest that produced it. `segment_index`, which sits beside it, is a position in the daemon-wide loaded-reader list: it is **not** convertible to `segment_seq`, and the difference between them was measured at 1, then 18, then 17 on one host within hours as unrelated segments came and went. Deriving one from the other unmaps every hit at once and scores a plausible, uniform 0% instead of erroring. `segment_index` is unchanged for existing consumers. ## [0.5.57] - 2026-08-07 ### Fixed - **A CPU-only daemon can verify the governance receipts it mints.** Erasure, `compact_facts` erasure, `memory_forget` and held hard-erasure overrides all persist their receipt as the observation envelope itself — Ed25519, passport signed, on disk. Until now none of the three documented surfaces could attest to one: `GET /v1/receipts/{id}` is 501 without a dataplane (by design), the verification endpoint's local fallback only understood *stream* receipts, and `corecruxctl inspect-receipt` searches sealed segments, where a governance receipt never appears. The daemon produced an audit artefact nothing could check. - `GET /v1/receipts/{id}/verification` gains a second local fallback for the observation-envelope shape, reporting `crux.governance_receipt_verification.v1`. A caller lacking scope for the receipt's *own* tenant gets **404**, byte for byte identical to a missing receipt, so the endpoint cannot be used as a cross-tenant existence oracle. - `corecruxctl inspect-receipt` resolves and verifies them too, printing kind, signer, body hash and signature status. - Both scans are bounded to `__governance__*` logs. This is a correctness constraint, not a tuning choice: a production node carries tens of thousands of per-session observation logs, and walking them all on an audit lookup would make verification a denial-of-service surface. - **`serde_json/preserve_order` is now declared where the wire format is defined.** `canonical_body_bytes` signs `serde_json::to_value(record)`, so map ordering *is* the signed format. `corecruxd` only had the feature because `crux-session` happens to enable it and Cargo unifies features across the graph — an accidental dependency that left the audit trail one dependency change away from every existing receipt failing to verify. The feature is now declared by `corecrux-receipts` and pinned by a golden-vector test. ### Changed - Observation record types, `canonical_body_bytes` and `verify_observation_envelope` move from `corecruxd` into `corecrux-receipts`. `corecruxd` is a bin-only crate, so `corecruxctl` could not otherwise reach the verifier; sharing beats a second copy of a signature check that can drift. ### Added - `corecruxctl start --agent `. - LlamaIndex and CrewAI adapters under the unchanged conformance suite. - Console Patchbay no longer freezes the tab when a system has more than 28 plans; drift reporting distinguishes a rejected credential from an unreachable daemon. ## [0.5.56] - 2026-08-07 ### Added - **A tenant's retrieval corpus can finally be erased.** Until now a corpus could be created but never retired, so a mis-ingested or superseded tenant was permanent — and there was no GDPR Art.17 story for the daemon. Two layers, deliberately separated by reversibility: - *Layer 1, logical erasure.* A persisted set of forgotten `(tenant_hash, watermark_segment_seq)` pairs, enforced in the BM25 scorers as a **required** parameter rather than an optional wrapper — so the compiler forces every present and future serving path to state its intent instead of one being silently missed. Reversible via `DELETE /v1/admin/forget-tenants/{tenantId}`. - *Layer 2, physical reclaim.* Opt-in per request (`"reclaim": true`), never automatic. Whole-tenant segments are evicted from the `IndexManager` and only then unlinked; mixed-tenant segments are retained, still masked, and reported as `mixed_segments_retained`. Irreversible — recovery is restore-from-backup only. - `POST /v1/admin/forget-tenants` (+ singular alias) requires `admin:write` **per named tenant**, with the whole batch rejected if any tenant fails and nothing masked. Reserved `__`-prefixed namespaces are refused by convention, so a future reserved namespace is protected the day it is introduced. - `GET /v1/admin/tenants/{tenantId}/footprint` reports segments, docs and bytes, so the blast radius is inspectable *before* anything is erased. - The whole surface is gated on `CORECRUXD_TENANT_ERASURE=1` and the routes 404 without it. Fact-store erasure remains out of scope: the response key is `corpus_erased`, never `tenant_forgotten`. - Key escrow M0–M3b, device-identity and CRL transport auth for the relay, and a span outcome dimension. - Console Patchbay — a spatial projection of the open work board (`GET /v1/work/graph`). ### Changed - Tenant scoping tightened across the stack: derived from the token, applied by type, exact-matched, and pushed down into gRPC and the lower layers. - Login verification is strict, and M1 auth fails closed. ## [0.5.55] - 2026-08-02 ### Added - **Account entitlement is now a verified property of a signed token, not an environment variable.** Three milestones of `crux-pro-capabilities-rcx-entitled-2026-07-27` land together, all **dark** — nothing enforces on them yet, `OperatingMode` still comes from `CORECRUXD_OPERATING_MODE`, and the cutover is a later milestone. - `corecruxd::entitlement` — an on-disk entitlement store on the reserved `__entitlement__::rcx` entity (born private, no freshness horizon), plus `resolve_entitlement`: revocation → presence → parse → tenant scope → signature → expiry → tier. Forgery and revocation always resolve `FreeLocal`; an expired token follows its own `FallbackPolicy`, so a paying user who is merely offline keeps working. - `corecruxd::pairing` — the daemon half of account pairing, reusing the RFC 8628 device grant already shipped at `/v1/auth/device/*`. No licence key and no typed secret: the `user_code` is a short-lived, single-use public correlator, useless without an authenticated browser session, and never written to configuration. - `OperatingMode::GovernanceHosted`. `MaxPrivate` remains a composite — Governance entitlement plus a private deployment shape — so no issuer can hand it out. ### Changed - **`RcxTier` cut over to the published ladder: `Free | Pro | Governance`.** The previous `Free | Pro | Team | Enterprise` matched no product CueCrux sells. `Team` is retired outright and `Enterprise` becomes `Governance`; `crux-enterprise-shim` re-gates accordingly (`not_enterprise_tier` → `not_governance_tier`). Retired values now fail *deserialisation*, which is what makes a stale token fail closed to `FreeLocal` rather than resolve to some default. `team_scope` and `enterprise_scope` stay on the token: removing either would change the signed bytes for every tier, not just the retired one. - **New spec version `rcx-ct/1.2`, accepted alongside `rcx-ct/1.1` rather than replacing it.** The spec-version check is not a version ladder but a match with a rejecting catch-all, so repointing the 1.1 constant would have failed every delegation token already minted. `1.2` is a *tier-vocabulary* version and is orthogonal to delegation: 1.0 forbids a delegation policy, 1.1 requires one, 1.2 makes it optional — a plain entitlement token carries none. A cross-language byte-parity vector pins the Rust and TypeScript encoders together on a `1.2` token with `tier: governance`. - **`corecruxd` split into sibling crates** — `corecrux-workspace-scan`, `corecrux-billing`, `corecrux-providers` and `corecrux-secrets` — with the unwrap ratchet re-baselined across the new boundary and `AGENTS.md` added for the extracted crates. - Desktop shell gains isolated Registry and WikiCrux tabs. - Workspace test coverage raised from 88.49% to 90.11% across the nine highest-debt files. ### Fixed - **Seat identity is taken from the credential, not the request body**, closing a bypass of the M8 per-seat rate ceiling on enriched verdicts. - **`dossier` and `storybook` now authorise against the tenant they answer for** (code-intel M3b). - `ledger:history` is no longer advertised as a Pro claim. The route is fully implemented but nothing produces a record, and selling a capability with no producer is a truth-in-selling problem; the route and its handlers stay. - `run_git` gained a real wall-clock deadline. `GIT_TIMEOUT_SECS` fed only `GIT_HTTP_LOW_SPEED_TIME`, an HTTP-transport setting that neither a local `git blame` nor an SSH remote ever consults, so the call could block unbounded on a stale `index.lock`. - `corecruxctl --version` now exists. It had no version surface at all, unlike `crux-hook` and `corecruxd`. - The console `.mcpb` is rebuilt so it ships Apache-2.0 rather than the retired CCL-1.0. - The config wizard parses profile frontmatter on a CRLF checkout. - The release path and a hang guard that was failing tests are unbroken. ### Security - **wasmtime 47.0.2 → 47.0.3**, clearing [RUSTSEC-2026-0222] (stores can mix up type indices between engines) and [RUSTSEC-2026-0223] (preemption and traps during bulk operations break internal VM state). Lockfile only; `wasmtime` is optional behind the default-off `wasm-extensions` feature, so default builds never compiled it. [RUSTSEC-2026-0222]: https://rustsec.org/advisories/RUSTSEC-2026-0222 [RUSTSEC-2026-0223]: https://rustsec.org/advisories/RUSTSEC-2026-0223 ## [0.5.54] - 2026-07-30 ### Changed - **Relicensed to Apache-2.0 — Crux Daemon is now open source.** The CueCrux Community Licence (CCL v1.0), a source-available BSL-style licence, is replaced by the **Apache License, Version 2.0** across the repository. The CCL already named Apache 2.0 as its Change Licence, so this brings that conversion forward for every version instead of waiting out the per-release three-year clock. Redistribution in competing products and offering Crux as a hosted service to third parties — the two rights the CCL withheld — are now granted, along with Apache-2.0's express patent grant (section 3). - `LICENCE.md` → `LICENSE`, containing the unmodified upstream Apache-2.0 text, so GitHub and SBOM scanners detect `Apache-2.0` instead of reporting an unrecognised custom licence. - New `NOTICE` file carrying the attribution required by section 4(d), plus the trademark and `content/` scope notes that must not be edited into the verbatim licence text. - Per-file headers on all 539 crate `.rs` files (and every script, workflow, proto, SDK source, and console asset) now read `SPDX-License-Identifier: Apache-2.0`. The contradictory "All rights reserved." line is dropped. - `license = "Apache-2.0"` in `[workspace.package]` and in the desktop shell, Python/TypeScript SDK, deb, Homebrew, and MCPB manifests; `LicenseRef-CCL-1.0` removed from the `cargo-deny` allowlist. - `scripts/check-licence-headers.sh` now enforces the Apache-2.0 header and SPDX line. Contribution terms in `CONTRIBUTING.md` are inbound=outbound under section 5 — no CLA. `docs/LICENCE-FAQ.md` rewritten; `docs/design/licence-recommendation.md` (the BUSL 1.1 proposal) marked superseded. - Curated content under `content/` keeps its separate licence (`content/LICENCE-CONTENT.md`) and is unaffected; that directory currently ships a placeholder manifest with no covered assets. - **Licence file layout deduplicated to a single GitHub licence tab.** `LICENCE-CODE.md` (a three-line stub pointing at `LICENCE.md`) is removed, and the content licence moved from the root to `content/LICENCE-CONTENT.md` — the directory it governs. GitHub's licence detector scans only the repository root, so it now surfaces one top-level licence (`LICENCE.md`, the code licence) instead of two tabs. `LICENCE.md` links to the content licence, and release bundles ship it under `content/` alongside the assets it covers. ### Added - **The token-burn cost lens gains a model/effort axis.** `crux-cost` already opened every transcript line by line and discarded what it found: `gitBranch` was parsed but used only as an internal ranking tie-breaker, and `model`, `effort` and `cwd` were not parsed at all. All four are now parsed onto the event, promoted onto `CostReport`, and rolled up into a per-model burn breakdown with per-effort burn *within* each model, rendered by both `corecruxctl session cost` and the console `cx-cost` page. - **It reconciles.** Per-model context, plus the `` pseudo-model, plus records carrying usage but no model id, sum exactly to `headline.measured_context_total`. - **Coverage travels with every effort number.** `effort` is absent from 61% of the measured corpus and its absence correlates with model — 100% / 100% / 22.5% / 9.4% across 46,239 assistant records — so a cross-model effort comparison is confounded at source and no sample size fixes it. `effort_coverage_pct` lives on the type, so no surface can render an effort figure without the denominator that qualifies it. - `` is reported separately, never ranked as a model and never dropped. Model-id normalisation folds only stable presentation variants (`[1m]` context suffix, `us.anthropic.`/`bedrock/`/`vertex/` route prefixes, `-v1:0`); floating aliases such as `opus` and `sonnet` are deliberately left unresolved, since resolving them would silently merge two models the day the alias moves. - The five new `CostReport` fields are additive, serde-default and omitted when absent, so an older daemon ignores a newer report and a legacy report is unchanged on the wire. A daemon must be upgraded before the console can render the axis. - Adds the cost lane's first integration coverage: a post-then-get test through both `/v1/cost/report` handlers. - **`CITATION.cff`.** Machine-readable citation metadata (GitHub "Cite this repository" button). Under Apache-2.0 citation is appreciated but not a licence condition. - **A Windows GUI smoke lane for the desktop shell.** `desktop-shell.yml` gains `desktop GUI smoke (windows, interactive session)`, running on a self-hosted runner that executes inside a real logged-on desktop (`[self-hosted, windows-gui]`). It bundles the MSI, installs it per-machine, launches the installed app, and asserts what only a desktop can show: a window appears, an `msedgewebview2` host actually starts, the bundled `corecruxd` sidecar is spawned from the `externalBin` slot, and a graceful close reaps that sidecar. A desktop screenshot uploads on every run, including failures. Non-blocking — it depends on a single operator-owned box, so an offline runner must not gate the merge queue. This closes the gap the native-Windows build fix left open: that change noted there was "no Windows job to catch the next one" of ~49 `#[cfg(unix)]` sites. Compiling was never the hard part — a Tauri/WebView2 window cannot be created in a headless or Session 0 context at all, so app launch was unobservable on every existing runner. `scripts/provision-windows-gui-runner.ps1` provisions such a box and refuses to run where a GUI cannot exist; `docs/self-hosted-runner.md` records why Server Core and Session 0 are each disqualifying, and what the lane deliberately does not cover (reboot re-attach, the Defender first-bind prompt, WSL2 parity with a developer box). ### Fixed - **The SessionEnd cost hook now records whether it worked.** The generated `cost)` launcher branch failed silently on three paths — `corecruxctl` absent, no configured endpoint, post rejected — each swallowed by `|| true`. Quiet is the correct posture for a SessionEnd hook, which must never block session end; undiagnosable is not. There was no way to distinguish "no sessions ran" from "every session silently failed to post", and the branch had no test coverage of any kind, which is why it survived. - Every attempt writes a one-line outcome record (result, reason, endpoint, endpoint source, launcher version) to `~/.claude/hooks/crux-cost.state.json`; failures additionally append to `crux-cost.errors.log`. `exit 0` remains unconditional on every path. - `CRUX_HTTP_URL` can never actually be empty — `${CRUX_HTTP_URL:-…}` substitutes for empty as well as unset — so the real misconfiguration is the *unconfigured loopback default*, which fails with a connection refused the operator cannot place. The launcher now records whether the endpoint came from config or from the built-in default, and names the remedy. - `corecruxctl hooks status` reports the last outcome and flags a stale launcher by version marker. The boot self-check warns when the installed launcher predates the running build, or when capture last failed or was never attempted — nothing warns when no outcome has been recorded yet, since a fresh install has simply not ended a session. - Six shell-level tests run the *embedded* launcher template under `bash`, so they cannot drift from what `hooks install` writes. **This takes effect only after `corecruxctl hooks install` is re-run.** An un-re-run wizard keeps the old silent script, and that is the most likely way this fix appears not to work. - **Three tests that asserted things the machine does not guarantee.** Each failed CI on an unrelated PR and passed on a re-run of the identical commit; two of them gate required checks, so they cost merges rather than just noise. - `envelope_build_latency_under_2ms_for_10_facts` (`crux-mcp`) asserted a wall-clock bound — the only one in the workspace. On a shared runner that measures how busy the machine is, not how fast the code is. The figure is still measured and printed; the assertion is gone, and the test is renamed `envelope_build_covers_all_ten_facts` after the check that carries content (`memories_used.len() == 10`). Raising the constant was rejected: it trades a frequent flake for a rarer one and keeps a load-dependent test. - `allowed_origins_reads_the_env_var` (`corecruxd`) round-tripped through `set_var`/`getenv` and read back the built-in defaults instead of the value it had just set — *while holding the module's `env_lock()`*. The mutex was never the problem: `setenv` can reallocate the environment block, and a concurrent `getenv` anywhere in this 2000-plus-test binary may then read a stale pointer, which no Rust-level lock can fence (hence `set_var` being `unsafe` from the 2024 edition). It now parses via `resolve_allowed_origins` directly, matching its five neighbours, and removes the last `set_var` in the file. - `pick_free_port_is_bindable` (`crux-shell-lifecycle`) asserted that a port picked by binding `:0` and dropping the listener is immediately re-bindable. Nothing promises that — sibling tests binding `:0` in parallel can take it in the gap. It now retries a bounded number of times, so only a genuinely broken helper fails. - **The desktop shell no longer opens stray console windows on Windows.** Two spawn sites, both console-subsystem binaries launched from a GUI-subsystem app (`windows_subsystem = "windows"`) that has no console to inherit — so Windows allocated a fresh console *window* for each: - `spawn_sidecar` (`crux-shell-lifecycle`) launched the bundled `corecruxd` with a visible console that sat beside the app for its entire lifetime. Redirecting stdout/stderr into the sidecar log did not suppress it: the streams and the window are independent. - the Windows credential lookup (`crux-shell-connection`) shells out to `powershell.exe` for the `PasswordVault` read, flashing a console on *every* attach-profile activation and retry. `-NonInteractive` governs the prompt, not the window. Both now set `CREATE_NO_WINDOW` (`0x08000000`) via `CommandExt::creation_flags`. `rundll32.exe`, used to open external links, is GUI-subsystem (verified from its PE header) and needed no change. Found by the new Windows GUI smoke lane on its first green run — the assertions passed while the uploaded screenshot showed the console box. That lane now fails if either window returns. - **A cold `cargo build` now completes on native Windows.** Three unrelated stops, none of which CI can see (every runner is Linux, and the release matrix is Linux + macOS — both unix): - `aws-lc-sys` assembles its x86_64 Windows routines with NASM, absent from a default Windows toolchain, so the build script aborted. The crate ships pre-assembled objects for this case; `AWS_LC_SYS_PREBUILT_NASM = "1"` now lives in `.cargo/config.toml`. Inert on Linux and macOS, where the prebuilt path is gated off by target. - `fsync_dir` (`corecrux-projections`) and `write_control_atomic` (`corecruxd`) bound a variable consumed only inside `#[cfg(unix)]`. Against the workspace-wide `unused_variables = "deny"`, that is a hard error off unix. Both bind the value under `#[cfg(not(unix))]` rather than renaming to `_path`/`_parent`, which would have suppressed the lint on unix where the variable is load-bearing. - **`cargo clippy --workspace -- -D warnings` now passes on native Windows.** Seven `clippy::unnecessary_wraps` errors across `corecrux-receipts`, `corecrux-storage`, and `crux-claude-hooks`: each is a directory-fsync or file-permission routine whose body is unix-only, so off-unix it collapses to `Ok(())` and the `Result` looks redundant. The signature is shared with a genuinely fallible unix implementation, so these are suppressed at the site with a note, not "fixed" by dropping the return type. Native Windows remains post-v1 per `docs/getting-started.md`; WSL2 is still the supported path. This only stops the gap widening — there are ~49 `#[cfg(unix)]` sites and no Windows job to catch the next one. - **`config.example.env` no longer claims a `.env` file is read.** The daemon has no dotenv support, so copying the file and starting `corecruxd` failed with "CORECRUXD_AUTH_MODE must be set explicitly" — indistinguishable from a genuine config error. The header now shows how to export the values. ### Security - **JWT HTTP fact storage is tenant-isolated by default.** The daemon freezes `CORECRUXD_TENANT_WRITE_STAMP` at startup, defaults it to `on` for JWT auth, rejects invalid configuration and ambiguous/missing tenant authority, and keeps `off`/`shadow` only as explicit shared-`default` migration modes. Generic and console facts, context recall, engram overlays, memory candidates, result envelopes, replay, and paired audit reads now preserve one authorized tenant end to end. MCP and stores with independent tenant contracts are explicitly outside this flag. - **`.mcp.json` is gitignored.** MCP clients write the daemon's agent bearer token into that file at the repository root, where it was previously committable. ## [0.5.38] - 2026-07-10 ### Added - **AST-derived code-structure scanner.** Behind `CORECRUXD_AST_SCAN`, the workspace scanner produces the `WorkspaceScan` shape from a `syn` AST pass instead of the regex scanner (flag-off byte-identical). ~17× faster on the Crux tree (p95 ~0.9 s vs ~15 s) and more accurate: call-edges resolved module-qualified, dead-code by AST identifier-reachability rather than the O(n²) substring pass. Context-graph edges fold in as `Extracted` confidence. - **Watched repositories.** `POST/GET/DELETE /v1/repos` register a repo the daemon should know about (tenant-scoped; `corecruxctl repo add|list|remove`; MCP `register_repo` / `list_repos`). Registering a local path runs a one-shot scan. An active file-watch loop (`CORECRUXD_REPO_WATCH`, default off) keeps a repo's graph current via incremental re-index — a single-file edit re-parses only that file — using `notify` with a WSL `/mnt` polling fallback. - **Polyglot extraction.** TypeScript/TSX, Vue (`