All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Warpline seam — issue lifecycle facts on the entity-association
reverse-lookup.
GET /api/entity-associations?entity_id=<sei>(and the MCP and data-layer reverse lookups) now enriches each binding row with the bound issue'sclaimed_at,closed_at,status, andstatus_category, so warpline can correlate "changed since the issue was claimed/closed" against its own changed-set in a single round trip.closed_atis the proven-good signal ("issue closed at commit X"); Filigree exposes the resolution timestamp and stores no commit SHA (warpline maps timestamp→commit on its side). Implemented as a separate enriched projection over aLEFT JOINtoissues— the shared mapper, the forward per-issue list, the add-response, and the governance closure gate are untouched and byte-identical; an orphaned binding still returns (null facts). Additive and Loomweave-safe (the consumer ignores unknown fields); the entity-associations contract fixture is bumped to v2. - Warpline seam — per-issue commit anchor at claim/close (schema v29). New
nullable
issues.claim_commit/issues.close_commitcolumns hold an opaque, caller-suppliedbranch@shathe issue was claimed/closed at, so warpline can correlate on commits rather than wall-clock timestamps. Filigree stores the anchor verbatim and never parses it (git/CI is Legis's domain) — thecommitargument is optional onclose,claim, andstart-work(CLI, MCP, and HTTP), and on the underlyingupdate_issue/reclaimpaths. The anchor is mirrored at everyclaimed_at/closed_atset and clear site (so a stale anchor never survives a release, reopen, unassign, reclaim, or undo), exposed on the issue read (classic + weft) and the entity-association reverse-lookup, andNULLwhen no commit is supplied — in which case warpline falls back to the timestamp. Additive and Loomweave-safe; the entity-associations contract fixture is bumped to v3. With nocommitsupplied, every existing flow is byte-identical.
- Warpline reverify ingest filed an issue and bound its SEI
non-atomically.
warpline_worklist_ingestcreated the issue and attached its SEI association in two separate transactions, so a non-retryable storage error on the bind left the issue FILED-but-UNBOUND — and the next ingest then re-filed a duplicate because the loop-closure contract keys on the SEI binding. File + bind now commit together in one transaction viacreate_issue's inline ADR-029 bind path.
3.0.1 - 2026-06-18
filigree doctorfalse-positive on FastAPI ≥ 0.137 — the dashboard route-registration checks ("Scan results routes", "Entity association routes") reported registered routes as missing on any install that resolved FastAPI 0.137 or newer, failingdoctor(exit 1) even though the routes are served correctly at runtime. FastAPI 0.137 madeinclude_routerlazy: child routes are mounted behind a_IncludedRouterwrapper and only compose their/api(and/weft) prefix at match time, so the doctor's flatapp.routespath scan could no longer see them. The check now resolves routes by Starlette matching instead of path-string scanning, which is correct across FastAPI versions. The dependency lock now resolves FastAPI 0.137.1 so CI exercises the version field installs actually receive.
3.0.0 - 2026-06-17
3.0.0 is a major release — the SemVer-major boundary that lands the breaking changes deferred through 2.x because they could not ship without breaking federation consumers (Loomweave / Wardline / Legis). The headline moves:
- Loomweave / Weft rebrand (schema v26) —
/api/loom/*→/api/weft/*,clarion:eid:→loomweave:eid:, outboundWEFT_TOKEN; no compatibility aliases. - Machine store consolidation —
.filigree/→.weft/filigree/, with the config anchor following (.filigree.conf→.weft/filigree/config.json). - MCP tool-name namespacing (ADR-016 Phase 2) — the legacy flat tool names are removed.
- Federation auth on-by-default — the inbound bearer token is
auto-provisioned at the anchor and resolved through
WEFT_FEDERATION_TOKENwhen an operator override is needed. - Legis governance — fail-closed closure-gate enforcement for governed issues, on every close surface.
- Agent work surfaces — ready/startable semantics, atomic start verbs,
priority range filters, active-only finding work views, suppression-aware
finding rollups, and the visible
filigree observation createalias. - Migration and server-mode hardening — crash-convergent store/config migration, daemon busy-probes, write fences, per-project federation tokens, scoped server-mode federation writes, and project echo headers.
- Smaller breaks: the deprecated
get_statsalias keys are removed and the internalbackwardflag becomes aTransitionModeenum.
Upgrade path: stop all writers (daemon, MCP sessions), then run
filigree init in each project — the store and config migrations are
idempotent and crash-convergent. The full old→new tool-name table and the
operator checklist live in UPGRADING.md.
- Internal
loom→weftnaming sweep;loom://URI scheme retired (filigree-73a2d91f5c). The last Loom-era names are gone: handler/type/test identifiers (is_loom_scoped_path→is_weft_scoped_path,IssueLoom→IssueWeft, …), the contract-fixture tree (tests/fixtures/contracts/loom/→…/weft/), and the bearer-token / URI-scheme design docs. There is no live federation URI scheme: the plannedloom://scheme was formally closed by the hub's SEI standard, andweft://is reserved for future federation-level resources, not active. Wire shapes are unaffected (the/api/loom/*→/api/weft/*route flip shipped earlier in this release). - Machine store consolidation:
.filigree/→.weft/filigree/(filigree-37e3f26145). The machine-owned store (SQLite database,config.json, scanner/template metadata, runtime sidecars) moves from.filigree/to the federation convention.weft/filigree/, sharing the.weft/subtree with the other Weft tools. Operators can relocate it with aweft.toml [filigree] store_diroverlay (honoured at init and at runtime; filigree never writesweft.toml). Existing installs migrate on their nextfiligree initviamigrate_store_to_weft— idempotent and crash-convergent, with atomic copy-then-publish at every step (see the migration fixes below). Legacy.filigree/store resolution is retained as permanent back-compat for unmigrated installs. Stop all writers before migrating (see UPGRADING.md): a live daemon holding the legacy DB is detected and refused, but an in-session stdio MCP connection cannot be detected and must be quiesced by the operator. - Config-anchor cutover:
.filigree.conf→.weft/filigree/config.json(filigree-4bf16e64b6). The project anchor moves off the legacy root file.filigree.confinto the store's ownconfig.json— completing the WEFT store consolidation for config the waymigrate_store_to_weftdid for the database.filigree initnow imports a present.filigree.confinto.weft/filigree/config.json(conf-wins on the fields the runtime served —prefix/enabled_packs/registry_backend/loomweave/project_name;modestays config.json-authoritative;dbis dropped) and retires the conf to.filigree.conf.imported— idempotent and crash-convergent (config.json is written atomically before the conf is atomically renamed). Fresh installs are born confless; the project anchor is now the presence of.weft/filigree/(or an operatorweft.toml [filigree].store_diroverride).weft.tomlis never written by filigree and never holds identity (the C-9c deletion test: filigree boots with noweft.toml). Implicit agent-startup surfaces (generate_session_context, the agent dashboard hook, stdio-MCP with no--project) resolve viafind_filigree_anchor(include_legacy_dir=False)so a bare legacy.filigree/ancestor is still not treated as attach-consent. Legacy.filigree/store reads are retained as permanent back-compat (resolve_store_dirfallback, C-9f); only the conf anchor is demoted to one-shot-import-and-retire. Existing installs migrate on their nextfiligree init; nothing breaks until then. Becauseconfig.jsonis now the sole identity authority (no conf backstop), a present-but-corruptconfig.jsonis refused at open with a structuredVALIDATIONerror — symmetric with a corrupt conf — rather than being silently defaulted to the directory name (which would write issues into the wrong namespace). - Loomweave / Weft rebrand (schema v26). The Clarion→Loomweave (sibling/
registry/SEI) and Loom→Weft (federation + named API generation) renames land
as a hard wire-break:
/api/loom/*→/api/weft/*, the entity-association keyclarion_entity_id→loomweave_entity_id, the SEI prefixclarion:eid:→loomweave:eid:, finding rule-idsCLA-→LMWV-, and the token env varCLARION_LOOM_TOKEN→WEFT_TOKEN. No compatibility aliases. The v26 migration rewrites every stored SEI prefix in place — the entity-association column, thedeleted_issuesF5 tombstoneentity_idsarray, and the entity-association audit events — plus finding rule-ids. Deployments must setWEFT_TOKEN(the opaque federation bearer token;CLARION_LOOM_TOKENis no longer read). Theregistry_backendvalue/section is nowloomweave(a deployedclarionconfig is migrated on load via a one-shot rename-on-load shim). Stored Legis signatures are stale-pending-reissue until Legis re-signs over the renamedloomweave:eid:entity_ids (Filigree never verifies them, so reads do not break). The registry error codes are renamed with the rest:CLARION_REGISTRY_VERSION_MISMATCH→LOOMWEAVE_REGISTRY_VERSION_MISMATCHandCLARION_OUT_OF_SYNC→LOOMWEAVE_OUT_OF_SYNC. The dormantloom://URI scheme is NOT renamed (closed/historical; tracked with the remaining federation-name residuals, filigree-73a2d91f5c). - MCP tool-name namespacing — legacy flat names removed (ADR-016 Phase 2).
The ~115 flat MCP tool names (
get_issue,list_findings,start_work, …) were renamed to the subsystem-namespaced<entity>_<verb>convention (issue_get,finding_list,work_start, …) in 2.3.0, which served the new names while still resolving the old ones as a transition window. 3.0.0 removes that fallback:call_toolrejects a legacy name with the standardNOT_FOUND(Unknown tool) envelope. MCP consumers that hardcode tool names must switch to the new names — see UPGRADING.md for the full old→new table. Callers that readlist_toolsdynamically are unaffected (it has served only the namespaced names since 2.3.0); the CLI surface is unchanged. The 2.3.0 deprecation-telemetry field (deprecated_tool_name_callsinmcp_status_get/mcp-status) is removed. TransitionModeenum replaces the internalbackward: bool(internal Python API). The transition-direction flag that was conflated with force/escape semantics acrossTemplateRegistry.validate_transition,update_issue, theDBMixinProtocolsignature, andInvalidTransitionErroris now aTransitionMode{FORWARD, BACKWARD}enum (filigree.types.api.TransitionMode);InvalidTransitionError.backwardbecomes.mode. The flag has no MCP/CLI/HTTP/wire exposure, so it is replaced outright with no compatibility alias. Only callers of the internal Python API (backward=True→mode=TransitionMode.BACKWARD) are affected; the close/reopen/release behaviour andtransition_forcedaudit events are unchanged.get_statsalias keysstatus_name_counts/status_category_countsremoved. Deprecated in 2.1.0 (filigree-17694d2db8) as exact duplicates ofby_status/by_category, they are now dropped from every surface that carriesget_statsoutput: the MCPstats_gettool, the MCPsummary_getJSON envelope (nested understats), the HTTPGET /api/statsprojection, and thefiligree stats --jsonCLI output. The keys never held information beyond the canonical pair, so the drop loses no data. Migration: readby_status(literal workflow status names) andby_category(template categoriesopen/wip/done). The publicGET /api/statsendpoint is the out-of-suite breaking boundary — pinned external consumers must switch. No in-suite sibling (loomweave / wardline / legis / lacuna / weft) read the removed keys (confirmed by full call-site enumeration, filigree-034931a584).
-
Warpline reverify-worklist consumer —
warpline_worklist_ingest(federation Seam 2A, weft-74f1e0c331). The write-capable half of the warpline↔filigree seam: consumes awarpline.reverify_worklist.v1worklist and files-or-links its items as work. Per item, keyed on the entity SEI — already-tracked-by-an-open- issue →linked; untracked →filed(a task carrying thewarpline/federationproducer labels plus an ADR-029 entity association on the SEI, the same surface warpline reads back viaentity_association_list_by_entity, so a filed item shows as tracked on the next worklist); no SEI →skipped. Explicit-action only: previews by default (apply=false, pure reads),apply=trueperforms the writes. warpline never auto-files. MCP-only, matching the ADR-029 federation surface precedent. -
filigree observation create— visible alias offiligree observe(dogfood N-7, filigree-ce3bfae865). The natural first-tryfiligree observation createpreviously failed with a hintlessNo such command 'create'. The alias shares observe's callback and options (no fork) and is marked as an alias in the group help;observestays the canonical flat verb. -
Priority range filters on the listing and ready surfaces (dogfood N-6, filigree-7fe21777b6).
--priority-min/--priority-maxonfiligree listandfiligree ready, andpriority_min/priority_maxon MCPissue_listandwork_ready— the same range pattern the claim verbs andobservation listalready had, applied SQL-side inlist_issues/get_ready.--prioritystays exact-match sugar; combining it with a range bound is aVALIDATIONerror. -
Claim verbs accept
actoralone as the caller identity (dogfood FIL-3, filigree-3028a8d0f8).work_claim/work_claim_next/work_start/work_start_nextno longer hard-requireassignee: when omitted it defaults fromactor(completing the existing actor-from-assignee direction into bidirectional defaulting); omitting both is a handler-levelVALIDATIONerror naming both params. On the CLI, an explicitly passed--actorfills an omitted--assigneeonclaim/claim-next/start-work/start-next-work; the implicitclidefault never claims anonymously.work_reclaimis deliberately excluded — its assignee is the transfer recipient, not the caller. Strictly additive: every pre-existing call shape is byte-identical, pinned by regression tests. -
Federation auth is now on-by-default: the anchor auto-provisions the inbound token (3-tier resolution).
filigree installanddoctor --fixpre-seed — and the daemon mints on first serve — a per-machine token at<store_dir>/federation_token(mode 0600, gitignored). The resolver reads three tiers, highest first:$WEFT_FEDERATION_TOKEN(operator override; the only cross-host tier) → the store-dir token file → absent (auth stays off, graceful degrade). Because tier 2 auto-mints, the/api/weft/*+/mcpsurface is bearer-gated by default after a daemon's first serve; on-host siblings read the token from the.weft/subtree they can already read. A mint that cannot persist (read-only mount, full disk) is fail-loud — stderr + structured log — and an ethereal daemon pins the in-memory token so the enforced posture matches the report. A newfiligree rotate-federation-tokencommand rotates the file atomically (effective at the next daemon restart, and it says so). Resolving the token through a deprecatedFILIGREE_*alias now logs a deprecation warning namingWEFT_FEDERATION_TOKEN. This is deconfliction plumbing, not an authority key; the env var remains the cross-host escape hatch. -
finding_listfilter axes:kind,suppression,qualname,rule_id(FIL-2/X-5, weft-d7273d61e3). The consumer surface previously filtered only on flat top-level columns, so excluding wardline'skind:metricengine telemetry or already-suppressed defects meant pulling every finding and filteringmetadata.wardline.*client-side. The relevant wardlinewhere:{}axes are now flat parameters:kind(defect/fact/classification/metric/suggestion),suppression(active/baselined/waived/judged/all),qualname(exact match), andrule_id. Wired through the singlelist_findings_globalchokepoint so every surface gets parity: MCPfinding_list, CLIlist-findings(--kind/--suppression/--qualname/--rule-id), HTTPGET /api/weft/findings, and the/files/_schemadiscovery endpoint. Headline query:finding_list kind=defect suppression=active. (path_globis deferred — weft-2b71565563.) -
Scan findings carry the linked issue's
issue_statusandissue_resolution(N6). A finding whose promoted issue was dismissed (not_a_bug) previously read as open work; the linked issue's status andclose_reasonresolution now ride on every finding read surface (MCP,GET /api/weft/findings),NULLwhen unlinked. Re-promoting a finding linked to a closed/dismissed issue returns that issue with a warning naming the done state instead of minting a duplicate bug. -
finding_promoterefuses a wardline-suppressed finding withoutforce(weft-171fc22a50). A baselined/waived/judged finding is an already-accepted defect, not active work — promoting one manufactures false work. Promotion now failsVALIDATIONunlessforce=trueis passed explicitly (recorded as a warning on the result), threaded through both promote tools and both weft HTTP routes. -
WEFT_FEDERATION_TOKENcanonical inbound federation bearer + token negotiation (filigree-0e4bc3d81a). The bearer that gates Filigree's own/api/weft/*+/mcpHTTP surface is now read fromWEFT_FEDERATION_TOKENfirst, falling back to the deprecatedFILIGREE_FEDERATION_API_TOKENandFILIGREE_API_TOKEN(soft migration — existing exports keep working; removal post-1.0). This is the inbound surface token and is distinct from the outbound registry tokenWEFT_TOKEN; it is federation/deconfliction plumbing, not a security secret. Server-modefiligree installnow writes the.mcp.jsonAuthorization header asBearer ${WEFT_FEDERATION_TOKEN}(it previously wrote none, so the transport could not authenticate) and negotiates a token: it reuses an exported one, prints a one-line migration for a deprecated-alias value, or mints + records one under the gitignored<store_dir>/federation_tokenand prints theexportthe operator must run (filigree cannot write the agent's process env).filigree doctornow fails the Claude Code MCP check when a streamable-http Authorization header references an env var that does not resolve — turning the previously silent/mcp401 (an agent coordinating blind) into a diagnosable connectivity check — anddoctor --fixrewrites a committed header from a deprecated token name to${WEFT_FEDERATION_TOKEN}(commit-safe; never writes a secret value). -
Reconciliation-debt list surface (B2). When the Legis closure gate defers a governed finding→issue auto-close (blocked or unconfirmable), the deferral is recorded as reconciliation debt. A new read surface lists the issues that carry it:
db.list_reconciliation_debt(), the CLI verbfiligree reconciliation-debt(--limit/--offset/--json), and the MCP toolreconciliation_debt_list(brings the served MCP surface to 116 tools). The debt write is idempotent, so re-evaluating the same blocked issue on every ingest/sweep does not duplicate comments. -
Legis governed-sign-off binding fields (B1, schema v25). The entity-association attach surface (
POST /api/issue/{id}/entity-associations) now accepts and persists two optional opaque fields Legis sends when it binds a cleared governed sign-off:signature(an HMAC over{issue_id, entity_id, content_hash, signoff_seq}) andsignoff_seq. Filigree stores both verbatim and echoes them back on every read (HTTP + MCPentity_association_list/_list_by_entity) — it has no key and never verifies the signature, exactly as it treatscontent_hash_at_attach. Both columns are nullable: Legis omits them when no key is configured, and pre-v25 / non-governed bindings readNULL. A re-attach that carries a signature refreshes the binding; a signatureless re-attach preserves the prior sign-off (sticky governance — see the v27 fix below), so a routine drift refresh never silently revokes governance. The attach idempotency key(issue_id, entity_id)is unchanged.export/importround-trips the new columns. Wrong-typedsignature/signoff_seq(incl. aboolfor the sequence) are rejected400 VALIDATION. -
Legis closure-gate enforcement (B5). Closing a governed issue — one with at least one entity-association carrying a Legis
signature— now consults Legis's read-only, fail-closed closure-gate first and refuses the close unless Legis confirms a verified binding. Enforced at every close surface (HTTP single + loom single + classic/loom batch, MCPclose_issue/batch_close, and the CLIclosecommand) via a shared transport-neutral policy, so no surface is a bypass; the data layer makes no network calls. Governance is off untilLEGIS_URLis set ("invisible until wanted"): ungoverned closes are unaffected and make no network call. When governance is on, a governed close is blocked (409) if Legis says no; if Legis is disabled (404) or unreachable it fails closed for governed issues (409, "governance backend unavailable") so the gate cannot be dodged by taking Legis offline; a tampered-ledger integrity failure surfaces as502. Batch closes report a blocked issue per-item without aborting the batch. New env:LEGIS_URL, optionalLEGIS_API_TOKEN. -
Transport-bound actor identity (ADR-012, schema v24). Every runtime write now records a
verified_*column alongside the claimedactor/author, holding the OS-user identity the process verifiably ran as (orNULLwhen no transport proof exists — all historical rows, unverified surfaces, and system-authored writes). Resolved at the CLI and MCP-stdio entry points. A non-blockingACTOR_MISMATCHwarning surfaces when the claimed and verified identities disagree (CLI: stderr; MCP: response-envelopewarningsarray); framework default actors (cli/mcp) are suppressed. No backfill; theeventsdedup index is unchanged;export/importround-trips the new columns. MCP-HTTP peer identity and dashboard auth remain deferred. -
scanned_pathsonPOST /api/loom/scan-results(and the classic/living aliases) — close-on-fixed now fires from scan ingest. A scanner can now sendscanned_paths: the authoritative set of files it visited this run, including clean files with zero findings. Whenmark_unseenis true, the absent-fingerprint sweep is driven off the union of files-with-findings andscanned_paths, so a file whose last/only finding was fixed (and is therefore absent fromfindings) is still reconciled tounseen_in_latestand its linked issue cascade-closes — eagerly, from ingest, no longer only via the age-gatedclean-stalesweep. Withscanned_pathsnon-empty, a fully-clean scan (findings: [],mark_unseen: true) is now valid instead of400. Optional and wire-compatible: a body omittingscanned_pathsbehaves exactly as before. Unknown clean paths (no prior file record) are skipped, never created. Wardline already emits this field; Filigree previously dropped it silently.
-
safe_messageparity for claim/transition errors on HTTP & MCP (filigree-d25e75cebf).ClaimConflictErrorandInvalidTransitionErrornow follow theWrongProjectErrorpattern: the untrusted HTTP/MCP error string is a fixed, genericsafe_message("Issue is claimed by a different assignee" / "Requested status transition is not allowed") instead of reflecting arbitrary call-site exception text on the wire. The structured recovery data is retained so agents still self-correct — claim conflicts keepdetails.observed/details.expected(the assignees); transition errors keepcurrent_status/type_name/to_state(andvalid_transitionswhen computed), now carried in the HTTPdetailspayload and the MCPTransitionErrorpayload even when no allowed-transition hint was enriched (previously these were only in the human string). The CLI keeps the full richstr(exc)operator message — it is the local diagnostic surface and is unchanged. Not breaking: assignees/statuses/transitions are coordination data, not confidential, and remain available in structureddetails; only a consumer that string-matched the prose of these two error messages over HTTP/MCP (rather than switching oncode+ readingdetails) is affected, which the 2.0 envelope contract already directs against. Scope note: batch per-item failures (batch_close/batch_update) andAmbiguousTransitionErrorare intentionally out of scope — batch failures carry only structured coordination data with no probe-sensitive text, and there is noWrongProjectErrorbatch precedent to mirror. -
Accessibility: ARIA labels on icon-only dashboard buttons. Icon-only controls across the app, detail, graph, health, ready, releases, and workflow views now carry
aria-labels so screen-reader users get a meaningful name. -
Performance: dropped a redundant open-blockers query in the issue batch fetch (
db_issues), removing a per-issue round trip from the batch path.
-
Migration daemon-liveness probe no longer false-refuses on a deterministic-port collision with an unrelated listener (filigree-d5aa3bfe3d). The ephemeral-dashboard tier of the migration's busy-detection treated any listener on the project's deterministic port as a live dashboard — but
compute_porthashes into a 1000-port window shared with whatever else runs on the box, so an unrelated daemon on the colliding port aborted real migrations with a spuriousStoreMigrationBusyError. The probe now requires the listener to positively identify itself:GET /api/healthmust answermode == "ethereal". Non-HTTP listeners, non-filigree services, and server-mode daemons (the registry tier's call) no longer contribute a refusal; a pre-1.3.0 dashboard (nomodefield) reads as unidentified and falls through to the write-fence + quiesce backstops. -
Session-context banner named MCP verbs as if they were CLI commands (dogfood N-4, weft-993c1077e1, filigree-4e64621f70). The banner's backtick'd hints (
finding_list,finding_promote,observation_list) were MCP verb names that fail as shell commands, so an agent in a CLI-first session gotNo such commandas its first action. Hints are now runnable CLI commands with the MCP verb named in an(MCP: …)aside, and a regression test pins every backtick'd banner hint to afiligree …form. -
Session-context "actionable" finding count no longer presents engine telemetry as defect signal (dogfood FIL-1 residual, filigree-4d489560e0).
unbridged_finding_statsnow additionally splits the actionable bucket by wardline kind (actionable_defect/actionable_other; kind-less findings count defect-side so third-party scanner findings are never hidden as telemetry), and the ANALYZER FINDINGS banner line surfaces the split and steers triage tofiligree finding list --kind defectwhen telemetry is present. -
Server-mode
.mcp.jsonno longer leaks the per-machine federation token into git, anddoctorrecognises file-sourced auth (filigree-ebfc16a090, filigree-b09a4854d7). The server-mode install embedded the literal token into.mcp.jsonat umask-default 0644 with no gitignore guard — and the token is per-machine, so a committed copy makes every other clone present one machine's token and get 401s. The file is now chmod 0600 after write, a.mcp.jsongitignore rule is appended (server mode only; ethereal's token-less stdio entry stays committable), and an already-tracked file gets agit rm --cachedwarning. Separately, the doctor "Auth config" check read env vars only, so an on-by-default daemon authed via the auto-minted token file was reported "auth disabled" — it now resolves tier 2 read-only and reports the real posture. -
Store migration refuses to run while a live daemon holds the legacy DB (filigree-031f9a413f). The migration deletes the legacy database after copying it forward, so a daemon idle across the copy→unlink window could commit a post-copy write to the unlinked inode — silent loss that no copy-time lock can close (the write has not happened yet at copy time). Migration now runs a registry-based, PID-verified detect-and-refuse before any mutation: a live filigree daemon serving the project (server-mode registration or a bound ephemeral dashboard port) raises
StoreMigrationBusyErrornaming the port and the recovery. Detection is best-effort and can never crash the migration; an in-session stdio MCP connection cannot be detected and is documented as an operator quiesce requirement in UPGRADING.md. -
Resumed store migration no longer ships stale metadata (M1). The DB re-copies unconditionally while the legacy store is canonical, but
config.json/federation_tokencopied once — so a resumed migration could publish a stale snapshot of them. The metadata files now mirror the DB's re-copy rule (atomic re-copy while legacy is the resolved canonical store; copy-once after the weft store takes over, so a committed install is never clobbered by a stale legacy copy). Step 4 also unlinks the now-deadfederation_tokenfrom the legacy husk (secret hygiene). -
CLAUDE.md/AGENTS.mdinstruction-block rewrite is bounded — it can no longer delete a sibling tool's block (filigree-bcbd4d66fd). The shared instruction files are co-owned by filigree, wardline, and legis via namespaced fences, butinject_instructionshad no foreign-owner concept: when filigree's own end-marker was missing it truncated everything after its start marker to end-of-file — deleting any co-resident sibling block that followed — and the SessionStart auto-repair hook made that reachable on every session start. The rewrite now bounds filigree's writable region at the first foreign-namespace fence (case-insensitive); duplicate or unclosed filigree blocks still collapse to one, and a stale duplicate surviving beyond a foreign fence warns instead of silently shipping. -
Server registry survives a store relocation:
register_projectdedups by project root (filigree-a4925b59bb).server.jsonkeys entries by store-dir string, but a project's identity is its root: after the.filigree/→.weft/filigree/relocation the stale old-store key lingered with the same prefix, read as a collision, and the re-register was refused. An existing entry resolving to the same project root is now recognised as the same project's stale key and dropped — the registry converges to a single live key and self-heals. -
Server mode echoes
X-Filigree-Projecton every response, classic surface included (filigree-b62d865dad). An unscoped classic-router write (POST /api/issues) silently resolved to the daemon's default project with no header echo, so a misroute was undetectable. Every non-/mcpresponse now names the project it resolved to, uniform with the federation seam. Classic writes intentionally keep default-project resolution (the dashboard's no-project view relies on it); only the federation surface fails closed on unscoped writes. -
JSONL export→import round-trip preserves
file_events.actor(B4, filigree-673a1f3af5). Both file-event import INSERTs listedverified_actorbut omitted the claimedactor, silently resetting it to the empty string on re-import. Audit-trail fidelity fix; the trustworthyverified_actorcolumn already round-tripped. -
POST /api/v1/observationsis gated behind the federation token like its siblings (filigree-bd101dbfa0). The versioned classic write alias dispatches the same handler as the gated/api/observationsand/api/weft/observationsroutes but was missing from the alias set, so a federation producer could write the project DB ungated. Gate-coverage consistency (deconfliction, not a security hole); the/api/healthauth report lists it among the protected paths, and the cross-generation drift guard now iterates the classic router too. -
filigree sei-backfillworks on rebranded projects and preserves governance through merges (filigree-c31a22fd47). (a) The backfill read the pre-rebrand.clarion/clarion.db— which no rebranded project has — so every run failed the sync gate and the ADR-017 production migration could not run at all; it now reads.loomweave/loomweave.db, probing the forward-compat.weft/loomweave/location first so it keeps working when Loomweave consolidates its own store. (b) Merging a duplicate locator-keyed binding into its SEI-keyed survivor carried only the content hash and attach time — never the Legis sign-off columns — so deleting a signed non-survivor silently downgraded the issue governed→ungoverned. The merge now carries the freshest valid sign-off onto the survivor (signed wins, never downgrade, highersignoff_seqwhen both are signed); the content-mismatch edge lands governed-but-STALE, which fails closed — no path silently proceeds. -
Removing a Legis-signed entity association is refused — the closure gate's removal vector is closed. Schema v27 closed the write-clobber and blank-signature bypasses, but a plain
DELETEof a governed issue's only signed binding remained an independent governed→ungoverned downgrade that let the issue close with no Legis call.remove_entity_associationnow hard-refuses when the target row carries a signature (GovernedAssociationRemovalError); the predicate mirrors the gate's governed-ness test exactly, and it keys on the durable signature — not onLEGIS_URLbeing set — so unsetting Legis is not a back door. There is no signatureless override: Filigree holds no key and cannot verify a caller-supplied signature, so removal authority stays with Legis. -
A contract-violating Legis 2xx fails closed per-issue without poisoning the batch. A 2xx closure-gate response that does not affirm
allowed: truewas mapped toUNREACHABLE, which flips the cascade batch's Legis-down short-circuit and defers every remaining governed issue — but a 2xx means Legis answered; the defect is in that one response, not connectivity. A newCONTRACT_VIOLATIONoutcome blocks that issue (with reconciliation debt) while every later issue still gets its own gate call; 5xx and unexpected statuses keep the systemic short-circuit. -
Only an exact Legis 500 is a ledger-integrity failure; other 5xx degrade to
UNREACHABLE(filigree-6e0d7a6a82). The wire contract reserves exactly 500 for a tampered ledger, but the classifier mapped every status ≥ 500 toINTEGRITY_FAILURE— which the cascade deliberately does not short-circuit — so a transient 502/503/504 from a restarting Legis or an interposed proxy made every issue in a batch eat its own timeout. Availability fix; the posture stays fail-closed throughout. -
Batch closure-gate helpers fail closed on a gate-read error. The four batch helpers (HTTP + MCP, close + update) caught
ValueError/KeyErrorwhile reading governed-ness and appended the issue to the ALLOWED list — fail-open, the inverse of the single-close path. A gate-read error is now a per-itemVALIDATIONfailure that never routes the issue into the close; the foreign-prefix envelope abort is unchanged. -
filigree doctorno longer flags the retired.filigree.confas a missing anchor (filigree-4bf16e64b6 residual). The conf-anchor check predated the config-anchor cutover: it warned that the conf was "missing" and promised it "will be auto-written on next use" — but post-cutover nothing writes a conf, so the warning was unresolvable and fired on every healthy migrated project (including right after thefiligree initthat retired the conf). The check's polarity is now inverted to match the cutover: a missing conf passes (noting the anchor lives in the store'sconfig.json, and the.filigree.conf.importedbreadcrumb when present); a present conf warns that the legacy anchor is pending import and points atfiligree init. The conf-declareddbpath is still honored for unmigrated legacy installs, and the home-directory-conf warning is unchanged. -
HTTP / MCP-HTTP writes no longer silently drop
verified_author/verified_actor— the unverified posture is now discoverable (ADR-012). Only CLI and MCP-stdio can vouch for the caller (they stamp the OS identity); over HTTP theactoris a self-asserted claim and theverified_*columns are correctly NULL (stamping the server's OS user would be a false attestation). The drop was silent — callers had no signal. Both transports now expose anactor_verificationposture (verified,deferral, explanatorynote): on the dashboard/loom HTTP surface via the/api/healthauthscope, and on the MCP surface viamcp_status_get(where it is derived from the live session state, so MCP-stdio reads verified and MCP-HTTP reads unverified). Authentication itself — transport-bound caller identity — remains deferred tofiligree-81d3971467; this change makes the current state honest, not silent. -
Scan findings surface the wardline
suppression_stateat the top level. A finding that wardline has baselined/waived/judged carried that verdict only insidemetadata.wardline.suppression_state, so an agent triaging via the slimfinding_list(orfinding_get, orGET /api/weft/findings) could not tell an accepted/suppressed defect from open work without parsing nested metadata.suppression_stateis now lifted ontoScanFinding/ScanFindingWeft(mirroring the N6issue_statuslift);Nonewhen the finding is unsuppressed, and independent of issue-linkage. -
Agent finding work-views default to active-only — accepted defects no longer read as fresh, open work (filigree-2bdb878bd2, residual of the N2 wardline→filigree seam).
finding_list(MCP) andlist-findings(CLI) previously returned wardline-baselined/waived/judged findings mixed in with real ones atstatus:open severity:high, annotated but not hidden, so afinding_list status=open severity=highwork-query still surfaced already-accepted defects. Both surfaces now default tosuppression=active; passsuppression=allto include suppressed rows, or a specific verdict (baselined/waived/judged) to triage them. The default-hide lives at the agent surfaces only: the corelist_findings_globalprimitive keeps its all-inclusive default (internal callers are unaffected), and the federation read API (GET /api/weft/findings) and dashboard are likewise unchanged — federation consumers pass an explicitsuppression=filter.allis now an advertised suppression-filter value. Complements the already-shippedfinding_promotesuppression guard (which refuses to mint a new issue from an accepted defect withoutforce) and the session-context actionable/suppressed split. -
Scan trigger / status responses echo the file's findings posture (W2 class).
scan_trigger,scan_trigger_batch(per file), andscan_status_getreturned run metadata (run id, pid, log path) with no sense of the file's findings — a vacuous run-state-only green. They now carry afile_summary(severity-bucketedFindingsSummary); on the status shape it aggregates the run's target file(s) in a single query, reflecting post-ingest state once results are POSTed back. -
FindingsSummaryseverity rollups now carry asuppressedbreakdown so accepted defects are distinguishable from actionable work (filigree-c3e2b72f21). The row level already separated open status from the wardlinesuppression_state, but every summary producer (get_file_findings_summary,get_files_findings_summary,get_global_findings_stats, and the inlinelist_files_paginated/loom file-list summary) bucketed severity by open-status alone — so one active HIGH plus one baselined HIGH read as{"high": 2}with no way to tell them apart, and a federation consumer over-reported actionable high/critical work by the count of already-accepted findings. The fix is additive: the existing top-level buckets keep their meaning (every open finding, suppression-agnostic) and a parallelsuppressed: SeverityBreakdownis added, computed with the same shared classifier as the row-levelfinding_listsuppression filter (so the two cannot drift). Consumers can now derive actionable work asbucket − suppressed[bucket]; no existing number changes.GlobalFindingsStatsandEnrichedFileItem.summaryinherit the key. Counterpart of the weft federation interface audit gap G4. -
Session-context surfaces un-bridged analyzer findings (F2). A new
ANALYZER FINDINGS: N not yet bridged to the tracker (M actionable, K baselined/suppressed)line infiligree session-context(CLI + MCP) so an agent's orientation no longer silently reads "nothing to do" while un-promoted findings sit inscan_findings. Honest-empty: omitted at 0; the actionable/suppressed split uses the wardlinesuppression_state. -
Aggregate/container types are never offered as startable work (F3).
release,epic,milestone, andphasecarry a declarativecontainertype-schema flag;work_readyreports themstartable=false("complete child issues") andstart_next_workskips them, so the picker no longer hands an agent a release/epic container as if it were a unit of work. Manual transitions are unaffected. -
Confless store-migration re-run is idempotent. A completed confless migration (legacy DB carried forward + removed, no
.filigree.conf) no longer falls through to a needless re-copy or a spuriousStoreMigrationBusyErrorwhen a daemon is live —migrate_store_to_weftshort-circuits on the confless-completion state before the daemon-liveness probe. -
Store migration fails closed (not with a raw traceback) on a corrupt
.filigree.conf.migrate_store_to_weftpublishes the weft DB and copies metadata (steps 1-2) before rewriting the conf'sdbfield (step 3); a present-but-unreadable conf made step 3'sread_confraise after those mutations, escapingfiligree init/installas an uncaught traceback and leaving a half-published weft husk. The conf read is now a strict pre-mutation gate (mirroring theweft.tomlI1 read): a present-but-unreadable conf raisesStoreMigrationConfUnreadableErrorbefore any filesystem mutation — the conf and legacy store are left byte-identical and a re-run converges once the conf is readable — and the install path reports it as a cleanexit 1with a fix-or-remove message. The gate sits after the idempotency no-op checks, so a migration that already completed and was corrupted afterward still no-ops rather than refusing. Not data-loss (step 4 never ran; legacy stayed canonical) — an availability/robustness fix. (filigree-obs-85b37a7cdc) -
Store-migration metadata sub-trees (
scanners/,templates/) copy atomically (filigree-197be8b501). Step 2 copied these directories withshutil.copytreestraight to the final path, guarded onnot dest.exists()— the same torn-then-skip pattern already fixed for the DB and the metadata files: a crash mid-copy left a partial directory that a re-run's existence guard mistook for a finished copy and skipped, publishing the partial. A new_atomic_copy_treecopies into a dest-dir temp thenos.replace-publishes, so the destination only ever appears complete; the copy-once guard stays but is now safe. (Lower-severity than the step-1 DB bug: the legacy.filigree/husk is retained, so the original always survives.) -
Store migration write-fences the snapshot→unlink window as defense-in-depth against an ad-hoc writer (filigree-39c6958f31).
migrate_store_to_weftnow holdsBEGIN IMMEDIATEon the legacy DB across the copy, conf-rewrite, and unlink, and copies the DB via the SQLite online-backup API (folding WAL frames and preserving theapplication_idwithout a checkpoint that cannot run under the fence). A writer already active at fence-acquire is refused withStoreMigrationBusyErrorbefore any mutation (superseding the old copy-time checkpoint-busy guard), and a short-/zero-busy_timeoutwriter gets a visibleSQLITE_BUSY. Known residual (intentional): a writer that opens the legacy DB and blocks on the fence during the hold commits to the orphaned inode after release (POSIX keeps an open fd writing to a deleted inode) — a silent loss that cannot be closed without the writer's cooperation. The mandatory operator quiesce (see UPGRADING — "Stop ALL writers before upgrading") and the daemon detect-and-refuse remain the real backstops; the fence narrows, it does not eliminate. -
Server-mode
.mcp.jsoninstall reports its actual file mode, not an assumed0600. The success message unconditionally claimedmode 0600while thechmod(0o600)that tightens the token-bearing file is best-effort — on filesystems wherechmodis a no-op that still returns success (WSL DrvFs, CIFS) the file stays at the umask default, so the message asserted a lockdown that never happened (the same false-0600-posture classd2597d0set out to fix, partially reintroduced in the message path). The install now stats the file afterchmodand states the real mode, appending "could not tighten to 0600 on this filesystem" when it differs. Posture-honesty, not hardening (the file is local, not world-writable, and the gitignore guard already prevents git-history leakage). -
read_token_filehonours its "unreadable → empty" contract for corrupt files. A non-UTF-8 federation-token file raisedUnicodeDecodeError(aValueError, notOSError) — now caught (with a warning) so it fails closed to "no token" instead of crashing server-mode auth / daemon boot /doctor. -
forceis now declared on thepromote_finding/promote_finding_and_attach_entityinput TypedDicts, restoring schema↔type agreement for the suppression-override added earlier in this cycle. -
Server-mode federation now scopes to the caller; ambiguous writes fail closed (weft-7a399b8124 / weft-23574069a1). In
--server-mode(one daemon, many projects) an unscoped federation request (bare/api/weft/*or a living alias) silently fell back to the daemon's default project, contaminating it with another project's writes. Two fixes, one root cause:- Routing. The whole federation API now honours an explicit scope — the
/api/p/{project_key}/…path or a?project={key}query (uniform with how/mcpis scoped) — and an unscoped write fails closed with 400 instead of a silent home-project write. Unscoped reads stay lenient. Every federation response carries anX-Filigree-Projectheader naming the project it resolved to, so a misroute cannot read as success. - Token auth. A project-scoped request is validated against that
project's federation token (or an operator
WEFT_FEDERATION_TOKENenv pin), no longer only against the daemon's home-store token — so a project presenting its own token no longer 401s. Server-modefiligree installanddoctor --fixnow embed the project's token in.mcp.json(not the home token); existing installs re-heal viafiligree doctor --fix, which also reports a.mcp.jsoncarrying a token the daemon will reject for its scoped route and flags multi-store token divergence when no env pin is set. Deconfliction / data-integrity + availability — not a security change.
- Routing. The whole federation API now honours an explicit scope — the
-
Instruction-file write hardening against 0-byte data loss (filigree-04bad2a2bf). Two defensive gaps closed around the CLAUDE.md/AGENTS.md/
.gitignorewrite path. (a) A refuse-to-empty guard: the shared atomic writer (_atomic_write_text) now raises before touching the filesystem if handed empty or whitespace-only content, so filigree's write path is structurally incapable of renaming a 0-byte temp file over a populated user file — every caller always has non-empty content, so an empty payload is corruption or a logic bug. (b) A cross-process lock:inject_instructions' read-modify-write is now serialised by a blocking exclusiveportalockerflock at.filigree/instructions.lock(mirroringephemeral.lock/server.lock), so two concurrent SessionStart hooks — or a hook racing a manualfiligree install— can no longer interleave and clobber each other's injection. Best-effort: when.filigree/is absent there is no shared project to race over, so the write proceeds unlocked. The nested.filigree/.gitignorenow listsinstructions.lock. -
Instruction-write lock now follows the resolved store dir (regression in the 3.0 store consolidation; filigree-04bad2a2bf). The cross-process lock above keyed its directory on a hardcoded
.filigree/, but 3.0 moved the machine store to.weft/filigree/— a freshfiligree initcreates no.filigree/at all, so the lock was silently bypassed on every SessionStart of a normally initialised 3.0 project (only legacy-migrated projects, which keep a.filigree/husk, accidentally still locked). The lock now resolves its directory viaresolve_store_dir, whose single precedence chain both finds the real store (.weft/filigree/) and guarantees every racing process picks the same lock location when both layouts are present — restoring mutual exclusion.ephemeral.lockalready resolved correctly;server.lockis home-dir scoped and unaffected. -
Store migration copies the DB atomically and re-copies unconditionally while legacy is canonical (filigree-37e3f26145).
migrate_store_to_weft's.filigree/ → .weft/filigree/DB copy went straight to its final path viashutil.copy2, and the step-1 guard keyed on file existence (not weft_db.is_file()). A crash mid-copy (SIGKILL/power loss) therefore left a truncated DB at the destination that a re-run mistook for a finished copy: it repointed the conf at the corrupt file and deleted the still-valid legacy DB — silent total loss of the issue database, contradicting the function's documented crash-convergence. The copy now stages to a temp file in the dest dir and publishes with an atomicos.replace(so the destination only ever appears complete). Crucially, step 1 re-copies the DB forward unconditionally while the legacy DB exists, rather than skipping when the destination merely looks valid: the database is conf-pinned, so until the conf commits to the weft destination the legacy DB stays canonical and can take writes after an interrupted copy — an intact-but-stale weft snapshot would, if published, silently drop every post-interrupt write. The atomic publish makes the unconditional refresh safe and cheap, and the committed case short-circuits at the top guard so re-copy never fires post-commit. -
An aborted store migration no longer orphans the legacy DB on confless installs (filigree-37e3f26145).
migrate_store_to_weftcreated.weft/filigree/(weft_store.mkdir) before the busy-abortable checkpoint, so a live writer holding the legacy DB raisedStoreMigrationBusyErrorand left an empty.weft/filigree/husk behind.resolve_store_dirthen declared that husk canonical purely onis_dir(), so the next confless open (no.filigree.confto pin the legacy DB) stamped a fresh empty database into the husk and orphaned the real issue data still sitting in.filigree/— reachable on the documented deploy recipe (a running daemon holds the legacy DB whilefiligree initmigrates). Conf installs were unaffected (the conf still pinned legacy). Fixed at two layers:resolve_store_dirnow keys the.weft/filigree/choice on DB presence, not bare directory existence — an empty weft husk never shadows a legacy store that holds the DB (this also defends against an empty husk left by a copy failure); and the eagerweft_store.mkdiris deferred until after the busy check passes, so a busy abort leaves no husk at all.find_filigree_anchorinherits the fix for free (it derivesstore_dirfromresolve_store_dir). Distinct from the atomic-copy fix above. -
doctor --fixrepairs instruction files andcontext.mdagain (filigree-f57cb498d4).--fixnow wiresCLAUDE.md,AGENTS.md, and the generatedcontext.mdback into its fixable set: instruction files via the non-destructive marked-block injection (which preserves surrounding user content), andcontext.mdregenerated from the DB. This partially reverses thedoctor --fixnarrowing in54cdd65for these filigree-owned/-managed artifacts;.gitignoreis intentionally left excluded (the user runsfiligree install --gitignorefor that).context.mdopens the DB via the anchor-aware constructors so a broken DB surfaces as "Cannot fix context.md" rather than aborting the whole doctor run. -
Governed→ungoverned closure-gate bypass via the signature field (schema v27). Two reachable paths defeated the closure gate by making a governed issue read ungoverned. (a) A blank-string
signaturewas stored verbatim and the gate's truthiness predicate read it as ungoverned — contradicting DECISION 1A ("governed = non-null signature"). (b) A signatureless re-attach (a routine drift refresh; the MCP surface and Legis-without-key both omit the signature) unconditionally clobbered a stored signature toNULL, flipping a previously-governed issue ungoverned so the gate skipped Legis entirely. Fix: the data layer normalises blank signatures toNULLand the gate classifies governed-ness byis not None; the re-attach UPSERT is now sticky —signature/signoff_seq/signed_content_hashchange only on a write that carries a signature, so only Legis (which signs via the HTTP route) can move a binding between governed states. A new nullablesigned_content_hashcolumn records the content the signature was cut over (the HMAC bindscontent_hash); when it diverges fromcontent_hash_at_attachthe sign-off has drifted and the gate fails closed with the newSTALEverdict (a409, no network call) until Legis re-signs over the new content — distinct fromUNAVAILABLEso a single drifted issue does not short-circuit a finding-cascade batch. Migration backfillssigned_content_hash = content_hash_at_attach WHERE signature IS NOT NULL;export/importround-trips it. Detects content drift, not identity drift (the v26 rebrand'sentity_idrewrite is resolved by Legis on the gate call). -
Cascade batch's Legis-down short-circuit no longer over-blocks ungoverned issues. When an earlier governed issue in a
close_resolved_findingsbatch proved Legis unreachable, the short-circuit handed every remaining candidate a syntheticUNAVAILABLEwithout re-running the gate's cheap local checks — so an ungoverned issue (which never touches Legis, DECISION 1A) appearing later in the same unordered batch was wrongly deferred and tagged with a spurious "governed issue … unreachable" reconciliation-debt comment. The suppression is now threaded intoevaluate_closure_gate(legis_known_down) and applied only at the point a network call would happen — after the ungoverned/governance-off/STALEshort-circuits — so ungoverned issues still PROCEED and close while a down/slow Legis is still bounded to one timeout per batch. No governance bypass: a governed, non-stale issue still fails closed asUNAVAILABLE. -
filigree init/installnow ship a nested.filigree/.gitignore. A project that tracks its.filigree/dir as committed payload (a shared team issue DB, or a demo) — or a naivegit add -Ain the window betweeninitandinstall— previously committed the SQLite write-ahead-log sidecars (-wal/-shm, which yield a torn/corrupt DB on checkout), rollback journals, migration backups (*.db.*-bak), logs, the per-run lock/pid/port andinstance_id, and the generatedcontext.md. The shipped nested ignore excludes all of those.filigree.dbandconfig.jsonare intentionally durable (committable when the dir is tracked) so the issue data still ships in the payload case; the project-root whole-dir.filigree/rule (added byinstall) remains the default that keeps the entire dir out otherwise. A header in the file documents the durable/ephemeral split. Committing the DB itself as a clean point-in-time artifact additionally needs WAL checkpoint-on-snapshot (see the separate WAL-hygiene task). -
Legis closure gate was bypassable through every non-
closewrite path. The gate (B5) was enforced per transport verb — onlyclose_issue/batch_close— butclose_issueroutes throughupdate_issue, so a governed issue could be driven into a done-category status, ungated, viaupdate_issue/batch_updateon MCP, HTTP (classic + weft), and CLI, and via the scan-ingest/age-out finding→issue cascade. All of these now consult the same gate through a single shared decision (governance.evaluate_status_change_gate); a status write that is not a real close of a governed issue makes no network call. The cascade (Design A, B2) fails closed for governed issues Legis blocks/cannot confirm and records reconciliation debt instead of auto-closing; the reopen-on-regress cascade is intentionally not gated. -
Finding→issue close cascade was unreachable from scan ingest. Re-ingest wired reopen-on-regress but never close-on-fixed: fixing code and re-scanning flipped the finding to
unseen_in_latestwhile leaving the linked issue open (the close helper had a single caller — theclean-stalesweep — that Wardline never invokes). Ingest now runs a close cascade symmetric to the existing reopen cascade (best-effort, in its own transaction, preserving terminal human decisions via thedone-category guard, surfacing failures inwarningsand per-failure logs). -
Finding→issue close cascade no longer closes an issue with an active sibling defect. An issue can link more than one finding (
update_finding(..., issue_id=...); seeget_issue_findings). The close cascade now skips the close when any other linked finding is still open (a non-terminal, non-unseen_in_lateststatus), checked under the writer lock — so resolving one of an issue's findings cannot close it while another is an active defect. This also resolves the same-batch reopen-then-close collision (a finding regressing in the same ingest now blocks the close). The guard lives in the shared close transaction, so the age-gatedclean-stalesweep gets the same protection. -
Entity-association surface polish (ADR-029). The forward (issue→entity) read projection only badges a content-axis freshness state it actually owns; the identity axis remains Clarion's (
unknown, never a fabricatedactive), per ADR-017's two-axis model.add_entity_associationnow declares_skip_beginon both the protocol stub and its@_in_immediate_tximplementation so the stub-signature contract test and the mypy override check agree. -
doctor --fixnow clears the stale server-registry entries it already flags. In server mode,doctorreports every registered project whose directory has vanished (Directory gone: …) with afiligree server unregisterhint, but--fixskipped them: those checks carry a dynamic, non-unique name (Project "<prefix>") that the name-keyed fixer table never matched, so each reported "manual intervention" despite a deterministic remediation.--fixnow routes them by a stablecode(server_registry_orphan) and removes them by their exact stored config key in one locked pass (newserver.unregister_projects), reporting each entry it unregistered. Gone-directory only — a live project re-registers on next use — and the data plane (issues/findings) is never mutated. -
MCP actor-mismatch warning no longer fails silently (PR #52 review #3). The ADR-012 surfacing block in
call_tool— resolve verified identity, build theACTOR_MISMATCHwarning, inject it into the response envelope'swarnings[]— was wrapped inexcept Exception: pass. Non-blocking is correct by design (a mismatch must never break a tool call), but log-less was not: any failure (import error,_inject_warningsbug,_verified_actorattribute error) dropped the warning with zero signal, and a systemic break would have made every MCP actor-mismatch invisible server-wide while the identity-verification feature appeared healthy. The handler now logs at DEBUG (exc_info=True) instead of swallowing — still non-blocking, never silent. The CLI path already surfaced the same mismatch on stderr.
-
Legis closure gate fails closed on contract-violating 2xx (B7, PR #52).
legis_client.check_closure_gatepreviously treated any HTTP 2xx asALLOWED, reading onlyreason/evidenceand neverallowed. A200 {"allowed": false}, a200with an empty/unparseable body (_read_jsonyields{}), or any interposed 2xx (proxy, cache, captive portal) therefore defeated the gate's fail-closed posture (DECISION 2) and let a governed issue close. The 2xx branch now requiresbody["allowed"] is True(the JSONtrueliteral — no truthiness coercion); anything else degrades toUNREACHABLE, which the governance layer maps to a fail-closed block. The wire contract is unchanged (200 {"allowed": true}= allow /409= blocked). -
Legis client strips the bearer across redirects + validates the URL scheme (B3, PR #52).
check_closure_gatesent theLEGIS_API_TOKENbearer on a plainurllibrequest whose default redirect handler copies request headers (minus content-length/content-type) onto the redirect target with no same-origin check — so a302from a compromised or open-redirecting Legis could re-send the token to an attacker-chosen host. The request now goes through a custom opener whose redirect handler dropsAuthorizationbefore following a redirect (benign redirects are still followed; normal token auth on a non-redirecting call is unchanged), and a non-http(s)LEGIS_URLis refused before any request or bearer attach. Defense-in-depth: exploitation requires a Legis-side open-redirect or a compromised Legis, not a critical on its own. -
Bidirectional back-pointer verification for git-worktree discovery. Worktree-aware anchor discovery previously redirected to a main worktree on the strength of a worktree's
.gitpointer alone. A spoofed.gitfile (shipped in an untrusted clone, aimed at a victim project'sworktrees/<name>admin dir) or a stale pointer (admin dir renamed, worktree removed but the.gitfile left behind) could silently latch discovery onto the wrong project's database. Discovery now verifies that the admin dir'sgitdirback-pointer resolves back to this.gitfile before redirecting; on mismatch or read failure the.gitentry stands as a project boundary. -
Dashboard
/mcpHTTP transport now requires a federation bearer token (#56). The streamable-HTTP MCP endpoint exposes high-privilege agent tools and was previously mounted unconditionally on the loopback interface even with no auth configured. It is now mounted only whenFILIGREE_FEDERATION_API_TOKEN(or legacyFILIGREE_API_TOKEN) is set; otherwise/mcpreturns404. Bearer enforcement on the mounted endpoint is unchanged (ADR-018; the loopback boundary remains ADR-012). Migration: if you use server-mode MCP (.mcp.jsonwithtype: streamable-httppointing at the daemon), setFILIGREE_FEDERATION_API_TOKENbefore starting the dashboard or the client receives404; the installer (filigree doctor/init) now warns when it writes a server-mode config while no token is configured. Ethereal (stdio) MCP is unaffected. (Superseded later in this release: the token is now auto-provisioned — see Added — so/mcpis mounted and bearer-gated by default, and the canonical env var isWEFT_FEDERATION_TOKEN.) -
Installer /
doctor --fixwrites reject symlinked targets (#54). The maintenance write paths (CLAUDE.md/AGENTS.md,.gitignore,.mcp.json,.claude/settings.json, skill directories, Codexconfig.toml, and their.bakbackups) now refuse to follow or write through symlinks, so a malicious repository cannot redirect those writes outside the resolved project root. -
Clarion registry fails closed on malformed responses (#53). A reachable but protocol-violating Clarion response (
cause_kind="invalid_response") is no longer treated as an availability failure: the local-fallback wrapper re-raises instead of silently re-attaching files to the local registry, which could otherwise mask a security-bearingbriefing_blockedoutcome. -
Hook module fallback uses Python safe-path mode (#57). When command resolution falls back to
python -m filigree, it now emitspython -P -m filigree, preventing an attacker-controlled project-localfiligree/package from shadowing the installed one during cwd-based module resolution when hooks run from the project root. -
Windows PID checks use a trusted absolute WMIC path (#55). Process command-line verification now invokes
%SystemRoot%\System32\wbem\WMIC.exeby absolute path instead of a barewmic, closing a Windows executable search-path / current-directory hijack when filigree runs from an untrusted project directory.
- Bumped
starlette0.52.1 → 1.0.1 (major). Full test suite green against the 1.0 line; no application-level ASGI changes were required.
2.3.0 - 2026-06-02
-
Opt-in bearer-token auth for the loom federation surface (ADR-018; HTTP generation:
loom). When the operator setsFILIGREE_API_TOKEN, requests to/api/loom/*and the living federation aliasPOST /api/scan-results(incl. server-mode/api/p/{key}/…mounts) must carryAuthorization: Bearer <token>or receive401 PERMISSION(withWWW-Authenticate: Bearer). Filigree finally honours the token Clarion already sends instead of ignoring it. Opt-in and wire-compatible: with the env var unset, behaviour is byte-identical to today (loopback boundary, ADR-012) and the middleware is not installed. A set-but-blank (empty or whitespace) token correctly installs no middleware — a blank string cannot be a secret — but now logs a startup warning so an operator who exported one is not left believing auth is on. The classic surface, the dashboard UI,/api/health, and/stay open. ReusesErrorCode.PERMISSION(no new error code). Constant-time comparison; a non-ASCII token is a clean 401, not a 500. Gates access only — binding a verified identity intoactorremains future work (filigree-81d3971467). -
Promote-by-fingerprint HTTP route + finding→issue status cascade (Wardline A2). New
POST /api/loom/findings/promoteturns a single true-positive into a tracked issue keyed on(scan_source, fingerprint)— the HTTP surface a scanner (e.g. Wardline) needs when it knows only its own fingerprint and speaks HTTP. Idempotent: re-promoting a fingerprint whose finding already links an issue returns{"issue_id", "created": false}with no duplicate; an un-ingested fingerprint returns404 NOT_FOUND.priorityaccepts"P2"or2. Backed by new read-onlyfind_finding_by_fingerprintand acreatedflag onpromote_finding_to_issue; runs on a private worker-thread connection (CONTRACT-E). A finding→issue status cascade now keeps the filed issue honest: when a fingerprint-linked finding goesfixedvia the clean-stale sweep its linked issue is auto-closed, and when the finding regresses toopenon re-ingest the issue is reopened — but only if the cascade was what closed it (gated on the most recent into-done transition's actor in the event history, so a human's reopen + reclose is always honoured). The cascade is best-effort (a workflow-forbidden close/reopen must not fail the sweep or the ingest), and a failure is now surfaced rather than swallowed:clean_stale_findingsand thePOST /api/loom/findings/clean-staleresponse carry awarnings[]field (additive and wire-compatible — mirrors the loom scan-results envelope, which already liftswarningsto the top level), and reopen-cascade failures on re-ingest ride out instats["warnings"](both HTTP envelopes) and are logged per-failure so a systemic "every cascade is failing" is visible in operator logs.GET /api/loom/findingsgains an optionalfingerprintfilter. No schema change (reuses existing columns and the event log). -
SEI conformance: locator→SEI backfill + two-axis freshness (ADR-017). Filigree can now re-key its opaque Clarion entity bindings from mutable locators (
{plugin}:{kind}:{qualname}) to durable, opaque SEIs (clarion:eid:<hex>). New operator-invoked verbfiligree sei-backfill(default dry-run;--executeapplies) resolves every storedentity_associations.clarion_entity_id— and every historicaldeleted_issues.entity_idstombstone (so theaffected_entitieschange feed is SEI-only after cutover, REQ-F-01) — through Clarion'sPOST /api/v1/identity/resolve:batchand rewrites it in place (column name, wire shape, and opaque storage unchanged). Idempotent and resumable: an already-SEI value is skipped, and Clarion rejects SEI-shaped input with 400 (REQ-F-02). Unresolvable locators are flagged ORPHAN and kept verbatim — never dropped — via the additive nullableentity_associations.migration_orphaned_atcolumn (schema v22). The capability probe now reads_capabilities.seiand degrades cleanly against a pre-SEI Clarion ("identity unavailable; nothing to migrate"). Conformance is proven, not asserted: the shared §8 oracle runs intests/federation/against both a Clarion stub (fast lane) and a liveclarion serve(faithful lane). The production cutover run remains owner-scheduled; this verb is the machinery, not the trigger. -
Deprecation telemetry for pre-rename MCP tool names. Every inbound
call_toolthat arrives under an old (pre-ADR-016) tool name is counted so the namespacing cutover can be tracked and the alias window eventually closed. The call still succeeds (see the deprecation window below); the telemetry is hardened so a malformed/old-name call cannot break dispatch.
-
MCP tool names are namespaced
<entity>_<verb>(ADR-016 §7). All MCP tool wire names were renamed to an entity-prefixed form so they no longer collide across subsystems — e.g.get_issue→issue_get,start_work→work_start,add_comment→comment_add,list_findings→finding_list,observe→observation_create. There is nofiligree_prefix: clients already surface tools asmcp__filigree__<name>.list_toolsnow serves only the new names;mcp_tools/rename.py::RENAME_MAPis the frozen, CI-validated old→new source of truth.Deprecation window (no hard break yet):
call_toolcanonicalizes an inbound new name back to its handler and still accepts the old names at dispatch, emitting deprecation telemetry per old-name call. Integrations should migrate to the new names now; a future release will remove the aliases. CLI verbs are unchanged — only the MCP wire surface moved. (Seedocs/plans/2026-06-02-mcp-tool-namespacing-rename-plan.md.)
- Parallel scan-results ingest.
_SCAN_RESULTS_LOCKwas removed so two concurrent scan-results POSTs overlap their (slow) Clarion HTTP file-identity resolution instead of serialising; resolution runs before the write window, so the two writers contend only briefly at the WAL writer lock. See the two Fixed entries below for the regressions this exposed and how they were closed.
-
Loom finding routes no longer leak a non-enveloped 500 on a concurrent write.
POST /api/loom/findings/promoteand/api/loom/findings/clean-stalecaught onlyValueError/sqlite3.Error(promote) or nothing at all (clean-stale), and there is no app-level catch-all — so an unguarded exception (e.g. aKeyErrorwhen a concurrent writer hard-deletes the finding/issue between resolve and promote) escaped as a bare Starlette 500 with no{error, code}body, breaking the switch-on-code envelope federation consumers depend on. Both routes now mirror the per-route guard idiom from the releases routes: an unexpected exception becomes a properly-enveloped500 INTERNAL, and clean-stale additionally mapssqlite3.Errorto500 IO. The promote path is more precise about the hard-delete race — it re-resolves the fingerprint and, only if the finding genuinely vanished, returns404 NOT_FOUND; aKeyErrorraised while the finding is still present (a real invariant violation, not a vanished row) is surfaced asINTERNALrather than masked as a benign 404. -
SEI backfill surfaces a corrupt tombstone instead of silently dropping it. A
deleted_issues.entity_idsblob that is corrupt JSON or not a JSON array decoded to[], so the whole tombstone vanished from the backfill with no log, counter, or orphan — violating the module's "never silently drops an orphan" contract (and notably less careful than the sibling breadcrumb on the scan-ingest path).run_sei_backfillnow emits alogger.warningnaming the issue and increments a newtombstones_corruptreport counter (surfaced in the CLI JSON), on both the dry-run and applied paths; the corrupt row is left verbatim for manual inspection, never rewritten. Only Filigree writes that column, so this signals corruption or tampering rather than a normal path. -
SEI backfill no longer mass-orphans live bindings on an incomplete Clarion response.
_parse_sei_resolutionaccepted whateverPOST /api/v1/identity/resolve:batchreturned without checking that every submitted locator was accounted for — unlike its file-path sibling_parse_batch_response, which has always enforced exactly-one-channel completeness. A locator a truncated or buggy Clarion dropped from all three channels (resolved/not_found/invalid) read asNonedownstream, which the backfill could not distinguish from an affirmative orphan: it wrotemigration_orphaned_aton a live, valid binding and reported it "unresolved". The parse layer now threads the submitted locators through and asserts each appears in exactly one channel, raisinginvalid_response(cause_kind) on a missing, unexpected, or multiply-claimed locator. An orphan is now only ever a locator Clarion affirmatively returned innot_found, never one that fell out of the response. -
Benign scan-run completion warning suppressed for unknown
scan_run_id. An enrich-only producer (e.g.clarion analyze) POSTs findings under ascan_run_idFiligree never created; the completion step no longer emits a "status not updated" warning for that tolerate-unknown case, so consumers are not trained to ignorewarnings[]. A real run that exists but cannot be transitioned still surfaces the advisory. Terminal-state runs (now includingtimeout, previously misclassified as non-terminal) are likewise treated as benign and logged at INFO rather than WARNING — the benign/actionable split derives from a singleTERMINAL_SCAN_RUN_STATUSESconstant so it cannot drift from the transition table. -
Agent-facing runtime prose no longer points at pre-rename tool names. Session-context (
hooks.py), project summary (summary.py), and thescan triggerresponse still told agents to invokelist_observations/promote_observation/dismiss_observation/get_scan_status— nameslist_toolsno longer serves. These response/hook strings are not part of the static tool surface, so the existing served-prose guard could not see them; they now use the namespaced names and a new CI guard (test_no_old_names_in_runtime_prose) fails on any directive ("Use/Run/Call X") that names a tool by an old name, keeping the runtime surface cut over. -
Concurrent same-path scan-results ingest no longer fails with a spurious
run migrate-registry400 in local / Clarion-fallback mode. Removing_SCAN_RESULTS_LOCK(to let Clarion HTTP resolution overlap) exposed a race the lock had masked:LocalRegistry.resolve_filemints a fresh id per call, so two concurrent POSTs for the same new path pre-resolve to two different ids. The winner commits; the loser's INSERT hits thepathUNIQUE constraint, re-reads the committed row, finds an id mismatch, and raisedValueError(...run migrate-registry...)— surfaced as an HTTP 400 telling the operator to run an unrelated DB migration. The_upsert_file_recordid-mismatch guard now adopts the committed row's id when the resolution is alocalbackend (the minted id is arbitrary, so the stored row is authoritative); a stable-id (clarion) mismatch is genuine registry drift and still raises. Registry-owned column sync is skipped on the adopt path so a winner's Clarioncontent_hash/registry_backendis never clobbered by the loser's empties. Adds same-path concurrent-ingest coverage (deterministic white-box plus a barrier-synchronised end-to-end test). -
Scan write paths now use the project's writer-lock discipline.
process_scan_results,update_finding, andclean_stale_findingsran their writes in lazyDEFERREDtransactions with noBEGIN IMMEDIATEand no busy-retry — unlike every other write surface — so once_SCAN_RESULTS_LOCKwas removed they relied solely onbusy_timeoutto avoidSQLITE_BUSY, an undocumented guard one refactor (e.g. aBEGIN-before-SELECT) from breaking. They now acquire the writer lock eagerly via@_in_immediate_txand recover transientSQLITE_BUSY/SQLITE_LOCKEDvia@_retry_busy, matchingcreate_issueet al.process_scan_resultskeeps Clarion HTTP resolution and scan-run completion outside the writer lock (the write window is an extracted retry-safe helper), so the parallel-ingest win is preserved. No behaviour or wire change; transaction atomicity is unchanged (the loop already committed once).
2.2.0 - 2026-05-31
- Loom
DELETE /api/loom/issues/{id}/dependencies/{dep_id}now returns anissue_foundfield alongsideremoved({removed: bool, issue_found: bool}). The endpoint is idempotent by contract: a DELETE between two valid-prefix ids returns200 {removed: false}even when a referenced issue does not exist, so retried DELETEs stay safe — but that absorption (classic and the CLI/MCP surfaces raise NOT_FOUND for a missing issue) could silently mask a typo'd id.issue_foundisfalseexactly in the missing-issue case andtruefor a genuinely-absent edge between two existing issues, letting callers distinguish the two without parsing the server log (the handler also now logs a warning on the absorbedKeyError). The addition is wire-compatible per the loom contract's stability clause;removedis unchanged.
-
File-identity types now make illegal backend/identity combinations unrepresentable. The
(registry_backend, file_id, content_hash)triple is a correlated invariant —localfiles carry aFileIdand the empty-hash sentinel,clarionfiles carry anEntityIdand a non-empty drift hash — butResolvedFileandFileRecordflattened it into one shape, so a local record with a hash (or a clarion record without one) type-checked.ResolvedFileis now a discriminated union (LocalResolvedFile | ClarionResolvedFile) keyed onregistry_backend, pinningfile_id/content_hashto the backend at the registry mint sites; all five keys remain shared, so consumers that read common fields are unchanged.FileRecordgained a__post_init__validator (mirroringScanFinding's enum guard) that rejects the two illegal cross combinations at construction — closing the runtime hole on hydration paths that reconstruct records from external payloads. -
REVERSIBLE_EVENT_TYPESis now derived from theReversibleEventTypealias (get_args(...)) instead of hand-re-listing the same eleven event names, removing one of three drift-prone copies. The exhaustivematchinis_reversible_event_typeis kept deliberately (it forces anassert_neverundo decision on every newEventType); a contract test now pins its True-set directly againstget_args(ReversibleEventType)so the alias, the tuple, and the classifier cannot drift apart.
-
Scanner run summaries no longer mis-count reports as "clean" when they contain real findings.
_analyse_files's summary loop classified a report ascleanvia a whole-text substring match for the"No concrete bug found"sentinel, while ingestion (parse_findings) splits the report into----delimited sections and skips only the sentinel section. A report pairing a real finding section with a separate sentinel section was therefore ingested as a finding but talliedclean— under-reporting (and masking) findings in the operator-facing summary. The summary loop is now section-aware and consistent with whatparse_findingsingests; priority buckets are counted per finding-section. -
MCP
import_jsonlnow codes validation failuresVALIDATION, notIO. The handler caughtValueError/OSError/sqlite3.Errorand coded them allIO, so a user-correctable data error (malformed record, unknown type, invalid status/priority, or a foreign-prefixWrongProjectError) surfaced as a transient IO failure — misleading callers that switch oncodeper the error-handling contract, and leaking the raw message instead ofsafe_message. It now mirrorsexport_jsonl:WrongProjectError→VALIDATIONwithsafe_message, otherValueError→VALIDATION,OSError/sqlite3.Error→IO. -
Restoring a saved filter now shows the correct "Done: Xd" pill label.
applyFilterState()calledsyncPillUI()— which reads#doneTimeBound's value to render the Done pill label — before it restored that dropdown's value, so applying a saved filter with a non-default window (e.g. 30 days) could render the pill with the previous/default window (e.g.Done: 7d) while the underlying filter used the saved value. The dropdown is now restored beforesyncPillUI(). -
The dashboard Select (multi-select) button now reflects its active state.
toggleMultiSelect()switches the button's styling by looking up#btnMultiSelect, but the toolbar Select button carried noid, so the lookup returnednulland the button never showed the accent (active) styling when multi-select mode was on. The button now carriesid="btnMultiSelect". -
Escape now closes a file detail panel, not just an issue detail panel. The shared side panel is opened for both issue (
state.selectedIssue) and file (state.selectedFile) detail, but the global Escape handler only calledcloseDetail()when an issue was selected — pressing Escape over a file panel cleared the search instead of closing the panel. The handler now closes a file panel viacloseFileDetail()whenstate.selectedFileis set. -
Opening a file detail panel no longer leaves a stale issue header. Issue detail writes issue-specific markup into the shared
#detailHeader, butopenFileDetailonly rewrote#detailContent— so opening an issue and then a file showed a panel whose header still identified the previous issue.openFileDetailnow clears#detailHeaderbefore rendering the file panel. -
Activity feed now distinguishes a load failure from an empty feed.
fetchActivityreturnsnullon a non-OK response and an array (possibly empty) on success, butrenderActivityShellrendered!events || length === 0identically as "No recent activity." — so a failing/api/activitylooked like a quiet, healthy-but-empty feed and hid the server problem. The render decision is now a pureactivityRenderState(events)returning"error" | "empty" | "list"(node behavior test), and anullresult shows a distinct "Could not load activity." state. -
Release-tree expansion now surfaces load failures instead of "no data".
fetchReleaseTreereturnsnullon a non-OK response (it does not throw), sotoggleReleaseTreecached thenull, itscatchnever fired,errorReleaseIdsstayed empty, and the panel rendered "No tree data available." for what was actually a failed load — with no retry affordance. A new pureclassifyReleaseTreeFetch(tree)(node behavior test) marks anullresult as an error so the existing error/retry state renders. -
Kanban drag no longer throws when the transitions fetch fails.
fetchTransitionsreturnsnullon an HTTP/network failure (it does not throw), but the drag-start handler normalized that intostate._dragTransitions = transitions || []and then iterated the rawtransitions(for (const t of transitions)), throwing aTypeErrorthat the.catchcould not see — so drag affordances silently stopped working. The target-set computation is now a pure, null-tolerantcomputeDragTargets(transitions)helper (covered by a node behavior test), and the handler consumes its result. -
Metrics view now renders zero observation counts as
0, not blank.renderObservationStatspassed numeric counts straight toescHtml, whoseif (!str) return ""falsy guard turns0into an empty string — so astale_count/expiring_soon_countof0rendered as a blank cell instead of an explicit0. The counts are now stringified before escaping (escHtml(String(n))), the locally-scoped fix;escHtml's contract is unchanged. (renderObservationStatsis now exported so the rendering is covered by a node behavior test.) -
migrate_from_beadsno longer aborts when the Beads DB has nodependenciestable. Theevents,labels, andcommentsreads already tolerated a missing table (OperationalErrorfiltered through_is_missing_table_error), but thedependenciesread ran unguarded — a Beads DB with issues but nodependenciestable raisedOperationalError: no such table: dependencies, and the outerBaseExceptionhandler rolled the whole migration back, discarding already-imported issues. Thedependenciesread now uses the same missing-table guard, yielding zero migrated dependency rows instead of aborting. -
Issue.__post_init__now rejectsboolpriorities.boolis anintsubclass, so theisinstance(self.priority, int)range check acceptedTrue/Falseand they would serialize aspriority: true/priority: falseinto agent-facing JSON, violating the int-only contract. (The MCP/HTTP create paths already rejected boolean priority; this closes the model-layer hole for code that constructsIssuedirectly.) The check now excludesboolexplicitly. -
Release-tree reads now run the server-mode foreign-prefix guard. The dashboard
GET /release/{release_id}/treehandler read straight from the DB without the_check_read_prefix_in_server_modeguard that issue and plan reads use, so in multi-project server mode a foreign-prefixed id fell through to theKeyErrorbranch and returnedRelease not found: {release_id}— echoing the cross-project id verbatim instead of the safe wording. A probe could thus distinguish "no such release here" from "wrong project" and read back the foreign prefix. The handler now applies the same route-boundary guard before the lookup, returning404/WrongProjectError.safe_messageindistinguishably from a same-project miss. Single-project (ethereal) mode is unaffected — the guard is a no-op when_project_storeis unset. -
Scanner pipeline now reports a non-zero exit on any ingest failure, not only a total wipeout.
run_scanner_pipelinepreviously returned failure only when every finding POST failed (api_files_posted == 0 and api_files_failed > 0), so a partial success (e.g. 5 findings posted, 3 dropped) or a failed completion POST that left the scan run un-completed both exited0— the orchestrator saw success while findings were silently lost. The guard now fires onapi_files_failed > 0, symmetric with the analysis-failure guard. (api_files_failedaggregates both dropped per-file POSTs and a failed completion POST, so it counts an un-completed run too.) -
register_file(_commit=False)no longer discards a caller's transaction on error. When the file-record upsert ran inside a caller-owned transaction (_commit=False, as the annotation path uses it), theIntegrityErrorand generic error handlers inregister_file/_update_existing_file_recordcalledself.conn.rollback()unconditionally — a full connection rollback that would discard the caller's prior uncommitted writes. The write now runs inside aSAVEPOINTand rolls back only to that savepoint when the caller owns the transaction (mirroringcreate_observation); the standalone (_commit=True) path is unchanged. Concurrent-insert recovery (requery the collision and retry it as an update) is preserved in both modes — the failed INSERT raised because the conflicting row is already visible in the read snapshot, so the requery finds it after the savepoint rollback too.
2.1.1 - 2026-05-30
Upgrade guide: Upgrading from 2.1.0 to 2.1.1.
issue_deletednow carriesaffected_entities— the entity bindings a delete cascade removed (F5 amplifier). A harddelete_issuecascades the issue'sentity_associations(ON DELETE CASCADE), silently dropping Filigree's side of every Clarion entity binding. The 2.1.0issue_deletedsignal named only the issue, so a consumer mirroring the reverse lookup (list_associations_by_entity) could not tell which bindings the cascade dropped and would surface a user-facing phantom issue. The tombstone now captures the boundclarion_entity_ids (sorted) before the cascade and surfaces them asaffected_entitieson the/api/loom/changesissue_deletedrecord — always present ([]for live-issue records); consumers purge the listed bindings on reconcile. Schema v20 → v21 (newdeleted_issues.entity_idscolumn,NOT NULL DEFAULT '[]', backfilled for existing tombstones; the migration is automatic on first open). Wire contract:docs/federation/contracts.md§F5. Consumer tracking:filigree-f3bf56554c.
2.1.0 - 2026-05-30
Upgrade guide: Upgrading from 2.0.x to 2.1.0.
-
Custom workflow packs must declare reverse escape paths. Workflow operations that reopen, release/revert, or force-close issues now validate against template
reverse_transitions. Custom packs that relied on the previous internal bypass must add the corresponding reverse edge or callers will receiveInvalidTransitionError. -
HTTP batch-close no longer accepts
force=trueby default.POST /api/batch/closeandPOST /api/loom/batch/closereject forced HTTP closes unless the dashboard starts with--allow-http-force-close. CLIfiligree close --forceand MCPbatch_close(force=true)are unchanged. -
Corrupt
issues.fieldsrows are no longer merged over silently.update_issue(fields=...)now refuses to merge into unparsable stored JSON. Operators or embedders that intentionally replace a corrupt value must passforce_overwrite_corrupt=True, which records acorrupt_fields_overwrittenevent with the raw old value. -
Duplicate audit-event writes now raise instead of disappearing.
_record_eventusesevent_seqto preserve same-second event bursts and uses a normalINSERT; true duplicate rows now raisesqlite3.IntegrityErrorso the caller's transaction can roll back instead of losing audit history. -
The internal
_commit=keyword was removed.claim_issueand_claim_next_with_priorno longer accept_commit=. Embedders composing lower-level DB operations inside an existing transaction should use the publicstart_work/start_next_workAPIs where possible, or the internal_skip_begin=Truepath only when they own the transaction boundary.
-
POST /api/loom/findings/clean-stale— findings retention over HTTP, for federation consumers (ADR-015). Exposes the existingclean_stale_findingscore method (already reachable via CLIfiligree finding clean-stale) on the loom generation so a federation consumer like Clarion — which cannot drive a CLI — can trigger retention. Soft-archivesunseen_in_latestfindings older thanolder_than_days(default 30) tofixed, scoped to a requiredscan_source; rows persist and a finding that reappears in a later scan auto-reopens (fixed→open) withseen_countintact. Request{scan_source, older_than_days?, actor?}→ response{findings_fixed, scan_source, older_than_days}; no auth (loopback-only,actorfrom body, mirroring scan-results);scan_sourceis required as an accident-guard (not an authorization boundary). Wire shape pinned attests/fixtures/contracts/loom/findings-clean-stale.json. Enables federation cooperation (ADR-002 §7); Filigree's finding lifecycle is correct whether or not a consumer ever calls it. No schema change; no MCP/CLI change (the CLI verb already exists). ADR-015 also records the REQ-FINDING-05 scan-run-create contract: Filigree tolerates an unknown client-suppliedscan_run_idand proceeds — no create endpoint — and a consumer not managing run lifecycle should sendcomplete_scan_run: falseso the path stays warning-free. -
delete_issue— general-purpose hard-delete with a federation tombstone (F5). New MCP tooldelete_issueand CLI verbdelete-issue(--force,--json) permanently remove an issue and all of its dependent rows (events, comments, labels, dependencies in/out, file associations, observation links, annotation links + close-out acks) in one IMMEDIATE transaction; children andscan_findingsorphan viaON DELETE SET NULL, entity associations cascade. The delete is irreversible — events are destroyed, soundo_lastcannot reverse it. Force-gated guards refuse by default unless the issue is terminal (a done-category status orarchived), has no children, and has no other issues blocked by it;force=truedeletes anyway (orphaning children, cascading inbound dependencies). Guard refusals raise the typedIssueDeletionRefusedError(aValueErrorsubclass) and surface asErrorCode.CONFLICTviaisinstance, not message-text matching. Because a hard-deleted issue leaves no row for theGET /api/loom/changesfeed to join,delete_issuewrites a row to a newdeleted_issuestombstone table, surfaced on/changesas anissue_deletedchange record cursored ondeleted_atso federation consumers (Clarion / Wardline / Shuttle) learn of each deletion exactly once. Schema v19 → v20 (newdeleted_issuestable, keyed on a VACUUM-stableseq INTEGER PRIMARY KEY AUTOINCREMENTwith aUNIQUEissue_id). -
Scan findings accept an optional
fingerprintas cross-run identity. When a finding supplied toprocess_scan_results/POST /api/v1/scan-results/POST /api/loom/scan-resultscarries a non-emptyfingerprint, lifecycle andseen_countare keyed on(scan_source, fingerprint)instead of the(file_id, scan_source, rule_id, line_start)heuristic — so a finding that moves lines keeps one stable identity, and two distinct findings at the same site (e.g. two taint paths into one sink) no longer collapse. The field is generic; any scanner may supply it, and findings without one keep the legacy heuristic unchanged. Surfaced on the read projection (ScanFindingDict, loomGET /api/loom/findings). Enables native Wardline emission per the Loom integration brief §3.B. Schema v18 → v19 (newfingerprintcolumn; the dedup unique index is rebuilt as a partial index over fingerprint-less rows, with a new partial unique index over(scan_source, fingerprint)). -
get_readyitems carry astartableflag (andnext_actionhint). Ready surfaces (MCPget_ready, CLIready/get-ready --json) now mark each itemstartable: true/false.falsemeans the issue is ready (open, unblocked) but cannot be transitioned into work in a single hop — notablytriagebugs, which walktriage → confirmed → fixing. Non-startable items also carrynext_action, the intermediate status to move through first. The CLI human-readablereadylist flags non-startable rows inline. -
Opt-in multi-hop
advanceforstart_work/start_next_work. Newadvanceparameter (MCPstart_work/start_next_work; CLI--advance) walks the shortest soft-edge path to the nearest working status when no single-hop wip target exists — e.g.start-work <bug> --advancewalkstriage → confirmed → fixingin one atomic claim+walk. Missing required fields surface as warnings rather than blocking; hard edges are never auto-walked (the confirm gate stays intact by default). Default off. -
transition_forcedaudit event (2.1.0 §1.1). Every_skip_transition_check=Truestatus change inupdate_issuenow emits atransition_forcedevent alongsidestatus_changed. Reviewers reading the audit trail can identify every workflow shortcut directly instead of inferring it from the absence of atransition_warning. Internal callers (reopen_issue, forceclose_issue,release_claimrevert,start_workrollback) all emit it automatically. -
--allow-http-force-closeopt-in startup flag (2.1.0 §1.1).filigree dashboard --allow-http-force-closeenablesforce=trueon the HTTP batch-close routes. Default-off; CLI / MCP unchanged. -
Typed
ClaimConflictErrorfor optimistic-lock CAS failures (2.1.0 §0.3).filigree.types.api.ClaimConflictError(ValueError)carries the failing issue id and the observed/expected assignee pair. Every CAS-failure path indb_issues.py(_check_expected_assignee,release_claim,heartbeat_work,reclaim_issue) raises the typed class; every dispatch site (5 indb_issues.py, plus the dashboard routes, MCP tools, and CLI surfaces) now routes viaisinstancerather than message-text matching. The class still subclassesValueError, so pre-existingexcept ValueErrorcallers continue to work — only the routing mechanism changed. Closes the project's own CLAUDE.md contract violation ("switch oncode, not message text") and removes a class of silent CONFLICT→VALIDATION downgrades triggered by future message rewording. -
Cross-product entity-association binding (ADR-029, Clarion B.7 / WP9-A). New
entity_associationstable (schema v15) binds Filigree issues to Clarion entity IDs as opaque strings. Four MCP tools —add_entity_association,remove_entity_association,list_entity_associations, andlist_associations_by_entity— front the binding for agent writes and both lookup directions; matching HTTP routes serve cross-product reads (notably for Clarion's forthcomingissues_fortool, B.6). The reverse-lookup surface (GET /api/entity-associations?entity_id=…,list_associations_by_entity) answers "what issues are about this code I'm reading?" in one round trip and uses theix_entity_assoc_entityindex. The binding is idempotent on the composite key: re-attaching refreshescontent_hash_at_attachandattached_atwhile preserving the originalattached_by, so drift refreshes don't overwrite the audit signal of who first bound the issue. Actor identity on HTTP writes runs through_validate_actorfor parity with other write routes. Filigree never parses the Clarion entity-ID grammar (ADR-003), preserving the federation enrich-only rule (loom.md §5); the audit is encoded as three named tests intests/test_entity_associations_federation.py, with the initialisation-coupling test exercising the reverse lookup under blocked sockets so the §5 invariant covers Clarion's primary call path. -
ADR-014 file-identity displacement to Clarion (
registry_backendflag). Filigree now treats Clarion as the federation file-identity authority when configured. A newRegistryProtocolabstraction withLocalRegistry(default, in-process SQLite) andClarionRegistry(HTTP) implementations sits behind everyfile_idresolution path, selected by theregistry_backendsetting (local|clarion)._ClarionLocalFallbackRegistrywraps the Clarion backend so transient HTTP failures fall back to local resolution underRegistryUnavailableErroronly — semantic refusals (briefing-blocked, see Security) deliberately bypass the wrapper. The single-file path isGET /api/v1/files; see Phase D additions below for the batch and capabilities surfaces. -
Clarion 1.0 wire CONTRACT-1 — batched file resolution (
resolve_files_batch/POST /api/v1/files/batch).RegistryProtocol.resolve_files_batchreturns aBatchResolutionTypedDict with four channels —resolved,not_found,briefing_blocked,errors— and amessagessidecar that the loop-fallback adapter populates from single-item exceptions so call sites can promote channel entries back into per-item exceptions without losing the original context.ClarionRegistrychunks requests atCLARION_BATCH_MAX_QUERIES = 256(Clarion's hard cap, returning 400/BATCH_TOO_LARGEon overflow). The scan-results pre-resolve path (db_files._pre_resolve_scan_file_records) and the migration-planning path (cli_commands._registry_migration_plan) were refactored to one batch call instead of N per-finding HTTP round-trips; a regression test asserts 300 scan-result findings produce exactly two batch HTTP POSTs (tests/api/test_registry_backend_integration.py)._ClarionLocalFallbackRegistry.resolve_files_batch_via_loopgracefully services primaries that only implementresolve_file(legacy fakes) so the abstraction is back-compatible. -
Clarion 1.0 wire CONTRACT-2 — Bearer auth via env-var-named token.
ClarionConfiggains an optionaltoken_envfield (defaultCLARION_LOOM_TOKEN);_resolve_clarion_auth_tokenreads it at construction and threadsAuthorization: Bearer <token>onto every outbound request viaRequestobjects (replacing the prior bareurllib.urlopencalls). 401 responses map toRegistryUnavailableError(cause_kind="auth")so the standard fallback policy applies uniformly. Whentoken_envis configured but the named env var resolves to empty, startup emits a WARN so operators can spot silent loopback-only fallback. Opt-in: whentoken_envis unset, noAuthorizationheader is sent and Clarion serves loopback-bound deployments unauthenticated per the 1.0 cross-product contract. -
Phase D federation handshake —
GET /api/v1/_capabilitiesprobe and dashboard rotation banner. ClarionRegistry now performs a capabilities handshake on first use and surfacesclarion_instance_id,clarion_api_version, andclarion_instance_rotatedon the dashboard's schema endpoint. The dashboard frontend (static/dashboard.html,static/js/app.js) renders a rotation banner when Clarion'sinstance_idchanges between handshakes, alerting operators to a Clarion restart that may invalidate cached file identities.EXPECTED_CLARION_API_VERSION = 1pins the resolver protocol version; mismatches refuse startup underclarionmode because no in-process fallback can mask a wire- contract change. Test coverage intests/unit/test_clarion_capabilities_probe.pyand the cross-process scaffolding intests/integration/(live-Clarion end-to-end runs). The federation launch runbook (docs/federation/registry-backend-launch-runbook.md) was updated with the Phase D handshake checklist.
-
Workflow templates now declare
reverse_transitionsfor controlled escape paths (2.1.0 §4.1). Built-in packs ship explicit reverse edges for reopen, release-claim revert, and forced close behavior, andupdate_issue(..., backward=True)validates against that reverse table instead of accepting the old internal transition-check bypass. Backward transitions still emittransition_forcedbeforestatus_changed, while normalget_valid_transitionssuggestions remain forward-only. Custom workflow packs that rely on reopen, release revert, or forced close must declare the correspondingreverse_transitionsedge; missing edges now raiseInvalidTransitionErrorinstead of silently bypassing workflow validation. -
Invalid transition failures now carry structured
valid_transitionsdetails (2.1.0 §4.2).InvalidTransitionErrorincludes avalid_transitionsattribute,close_issuepopulates it from the failing issue's current workflow state, batch close unwraps the attribute into per-item failures, and HTTP/MCP/CLI error envelopes include the same structured hints without requiring callers to parse message text or make a second transition lookup. -
create_issuenow prefix-checks caller-supplied parent and dependency IDs before local existence validation (2.1.0 §3.4). Foreign-prefixparent_idanddepsnow raiseWrongProjectErrorat the same structural boundary as other write surfaces instead of being masked as local not-found validation failures. -
update_issue(fields=...)now refuses to merge over corrupt stored fields unless explicitly forced (2.1.0 §3.3). When the currentissues.fieldsvalue cannot be parsed, the default update path raises instead of laundering the corrupt bytes into a clean JSON object. Callers can passforce_overwrite_corrupt=Trueto replace the corrupt value; that path records acorrupt_fields_overwrittenevent whoseold_valueis the raw stored column value and whosenew_valueis the replacement JSON. -
release_claimnow clears ownership and performs its wip→open revert in one IMMEDIATE transaction (2.1.0 §3.2). The revert target is resolved before the release update, then a single guardedUPDATEclears claim metadata and, when a template reverse target exists, rewritesstatus. The audit trail recordsreleased,transition_forced, andstatus_changedin the same transaction, so failures during forced transition event recording roll back both the status revert and assignee clear. -
reopen_issuenow records its status change, close-field cleanup, andreopenedevent in one IMMEDIATE transaction (2.1.0 §3.1). The method now uses the shared busy-retry / transaction decorator stack and passes_skip_begin=Trueto the innerupdate_issuecall, so a failure while recording the terminalreopenedevent rolls back the status and fields writes instead of leaving a reopened issue with no audit signal forundo_last. -
get_stale_claimspushes the modern lease-expiry check into SQL (2.1.0 §2.3). Rows withclaim_expires_at IS NOT NULLare now filtered viadatetime(claim_expires_at) <= datetime(?)in the WHERE clause, so a polling agent callingget_stale_claimsevery N seconds no longer scans every assigned non-done row through Python's_parse_issue_timestamp. The Python fallback path is preserved for legacy rows (claim_expires_at IS NULL) that predate 2.0's lease columns — those still use thelast_heartbeat_at/claimed_at/updated_atcomparison againststale_after_hours. Closes embedded-db H4 from the 2.1.0 panel review. -
start_work/start_next_workhold the writer lock only across the claim+update composite (2.1.0 §2.2). Candidate discovery (get_ready) and per-candidate template lookups (tpl.reachable_working_status) now run lock-free in the public wrappers; the new private_start_work_lockedis the only piece decorated with@_in_immediate_tx. Public signatures unchanged. A new_StartCandidateUnclaimableErrorinternal sentinel wraps claim-phase race failures so thestart_next_workiterator can distinguish "try a different candidate" from a user-supplied error in the transition phase (e.g. an explicit bogustarget_status), which propagates unchanged.start_workunwraps the sentinel to preserve its public API contract. Closes embedded-db M2 from the 2.1.0 panel review. -
Every write method on
IssuesMixinnow beginsBEGIN IMMEDIATEbefore the first SQL statement and retries transientSQLITE_BUSY/SQLITE_LOCKED(2.1.0 §2.1). New@_in_immediate_tx("op_name")and@_retry_busy(attempts=3, base=0.05)decorators indb_base.pyown the BEGIN/COMMIT/ROLLBACK lifecycle plus exponential backoff. Applied tocreate_issue,update_issue,claim_issue,heartbeat_work,reclaim_issue,start_work, andstart_next_work. Composed callers pass_skip_begin=Trueto inner methods so the outer caller's IMMEDIATE transaction is preserved. Removes the prior reliance on Python's implicit DEFERRED-then-write upgrade, which could surface rawOperationalErrorpastbusy_timeout=5000under multi-agent contention. The Phase 0 §0.1 CAS guard insideupdate_issueruns inside the IMMEDIATE transaction unchanged. Closes embedded-db H1 / H2 from the 2.1.0 panel review. Internal API:claim_issueand_claim_next_with_priorno longer accept a_commit=kwarg — composed callers now pass_skip_begin=Trueinstead._rollback_uncommitted_start_claimdeleted as orphaned (the decorator owns rollback forstart_work/start_next_work). -
_record_eventno longer silently dedups same-second collisions (2.1.0 §0.2). The events table grew a newevent_seq INTEGER NOT NULL DEFAULT 0column (schema v15 → v16) and the dedup UNIQUE index was rebuilt to include it._record_eventnow uses plainINSERT(notINSERT OR IGNORE) and computesevent_seqinline asCOALESCE((SELECT MAX(event_seq) FROM events WHERE issue_id = ?), -1) + 1so same-actor same-second emissions (heartbeat bursts, batch ops sharing one_now_iso()) land distinct rows. True duplicates (every column includingevent_seqidentical) now raiseIntegrityErrorso the caller's transaction can roll back rather than silently dropping the event. Backward-compat: previously suppressed events were never observed by callers anyway; the migration only catches the events that were always meant to be recorded. JSONLexport_jsonlautomatically includes the new column viaSELECT *; thebulk_insert_eventand import paths now forwardevent_seq(default 0) so round-trips preserve per-issue sequence numbers. Forward-only migration; historical rows backfill toevent_seq=0. Closes silent-failure C3 from the 2.1.0 panel review. -
Scan-results handlers no longer block the event loop on Clarion HTTP waits. All three
POST /api/.../scan-resultsroutes (classic, loom, living-surface) indashboard_routes/files.pynow wrapdb.process_scan_resultsinasyncio.to_thread, so the event loop stays responsive for other handlers while Clarion is being awaited. A module-level_SCAN_RESULTS_LOCK(asyncio.Lock) serialises concurrent scan-results POSTs to protect the sharedsqlite3.Connectionfrom the race the move-off-event-loop would otherwise enable. True parallelism of scan-results awaited a per-thread connection pool, later landed in 2.3.0 (filigree-d4237f486f). -
Clarion path-normalisation contract documented (CONTRACT-4). Both single-file (
GET /api/v1/files) and batch (POST /api/v1/files/batch) lookups send lexical, forward-slash, project-relative paths normalised at the boundary bydb_files._normalize_scan_path; disk presence is not required because Clarion resolves by itssource_file_pathcatalog key, not by filesystem probe. Theregistry.pymodule docstring and the ADR-014 Phase D section now record this invariant so future consumers don't add disk-existence guards that would break valid catalog-only lookups.
get_statskeysstatus_name_counts/status_category_countsare deprecated (filigree-17694d2db8). They have always been exact duplicates ofby_status/by_categoryrespectively, doubling the payload with no added information. They remain emitted as compatibility aliases on every wire surface (MCPget_stats, theget_summarystatsenvelope, and the HTTPStatsWithPrefixprojection) per ADR-009 §7 and will be removed in the next major. Readby_status/by_categoryinstead.
-
"ready" no longer falsely implies "startable" (filigree-406e6b7ee0).
get_readylisted open-categorytriagebugs as ready, butstart_work/start_next_workthen raisedInvalidTransitionErrorbecausetriagehas no single-hop transition to a wip state.start_next_worknow skips non-startable (and ambiguous, and template-less) candidates instead of throwing on the queue, returning the next startable issue or a clean empty result. A namedstart_workon such an issue still raisesINVALID_TRANSITION, but with an actionable message naming the intermediate status to move through first (or to passadvance), rather than the bare "no wip-category transition" text. The explicit-target_statuserror contract is unchanged. The multi-hopadvancewalk now also aggregates every hop's soft-enforcementdata_warningsonto the result, so an earlier hop's advisory (e.g. a missing-severitywarning ontriage → confirmed) is no longer hidden by the final hop. -
ForeignDatabaseErrornow points out malformed.gitfiles in its remediation text (2.1.0 §6.1). Discovery classifies.gitboundaries as directories, valid worktree-pointer files, ordinary gitdir files, or malformed files; when the boundary is malformed, the diagnostic tells the operator to fix or remove that.gitfile before runningfiligree init. -
Simultaneous claim contention now surfaces
ClaimConflictError(2.1.0 §5.1). When two agents race to claim the same issue, the losing writer now raises the typed conflict error instead of a plainValueError, keeping CLI/MCP/HTTP/batch routing onErrorCode.CONFLICT. Phase 5 also adds guardrails for reclaim-vs-heartbeat contention, busy retry behavior, mixed-typebatch_close, durable middle failures,start_workrollback over a prior same-agent claim, localget_issueread-tolerance for imported foreign-prefix rows, enum validation throughclose_issue, idempotent current-status updates, and submodule/worktree discovery boundaries. -
update_issueis now atomic on assignee under multi-agent contention (2.1.0 §0.1). Whenupdate_issue(and everything that routes through it —close_issue,batch_close,batch_update) observes a non-empty assignee at SELECT time, the subsequent UPDATE adds anAND assignee = ?compare-and-swap clause; a concurrent reassignment between read and write now fails the UPDATE atomically and surfaces asClaimConflictError(ErrorCode.CONFLICT). Previously the read-then-write window silently let the second writer overwrite audit-trail attribution. Closes silent-failure C1 / solution-architect C1 / threat T-001 / embedded-db H1 from the 2.1.0 panel review. Matches the CAS pattern already used byclaim_issue,heartbeat_work, andreclaim_issue. Unassigned issues do not trigger the guard — ADR-008 read-tolerance for unheld writes is preserved. -
Registry error envelopes no longer leak transport exceptions across surfaces. Scanner-lifecycle and observation/annotation paths that touch
registry_errorsnow classifyRegistryUnavailableError,RegistryResolutionError, and the newRegistryBriefingBlockedErrorinto structured envelopes at the CLI, HTTP, and MCP boundaries instead of returning aurllib/socketexception text. Regression coverage spanstests/cli/test_scanners_commands.py,tests/cli/test_annotations_commands.py,tests/cli/test_observations_commands.py,tests/mcp/test_scanner_lifecycle_tools.py,tests/mcp/test_annotations.py,tests/mcp/test_observations.py, andtests/api/test_files_api.py. -
JSONL import preserves registry metadata across round-trip.
import_jsonlwas droppingregistry_backendand the resolvedfile_idprovenance fields when re-hydrating file rows, so a Clarion-backed project that exported and re-imported through JSONL silently lost its federation identity. The importer now forwards the full registry-metadata block; pin intests/core/test_crud.py. -
start_next_workskips incompatible candidates instead of failing the whole iteration. When the candidate set contained rows in states the caller's actor was not permitted to claim (priority filter, type filter, or workflow-state filter), the iterator previously raised on the first incompatible row and abandoned the remaining ready queue. The iterator now skips incompatible candidates and continues, matchingclaim_next's documented "next eligible" semantics and the CLI contract that the call resolves to either a claim ornothing_ready. Closes the PR #43 release-blocker race where two agents requestingstart-nextcould both receivenothing_readywhile ready work existed. -
report_findingno longer leaks fallback scoping when the scanner registry is partially unavailable. The finding-triage fallback path scoped the registry lookup to the wrong session when a registered scanner's lifecycle row was missing, so subsequent triage operations in the same call could see an empty registry view. The fallback now re-resolves against the live scanner registry per call; pin intests/mcp/test_finding_triage_tools.py.
-
Dashboard startup structured logs now redact path-rich project discovery failures (2.1.0 §6.2). Structured log payloads use
exc.safe_messagewhen available, while stderr still prints the richstr(exc)form for human diagnostics andfiligree doctorfollow-up. -
HTTP batch-close rejects
force=trueby default (2.1.0 §1.1).POST /api/batch/closeandPOST /api/loom/batch/closenow return 400/VALIDATION whenforce=trueis set unless the dashboard was started with--allow-http-force-close. CLI (filigree close --force) and MCP (batch_close(force=true)) are unchanged — those surfaces sit inside a trust boundary that the unauthenticated HTTP endpoint does not. The newtransition_forcedevent fires inupdate_issuewhenever_skip_transition_check=Truetriggers a status change, so the audit trail records every workflow shortcut even when the validator was bypassed by an internal call site (reopen, release-revert, force-close, rollback). Schema unchanged (event type is a string column). Closes the privilege-escalation surface flagged as threat-analysis T-002 in the 2.1.0 panel review. -
WrongProjectErrorno longer leaks project prefixes via HTTP / MCP (2.1.0 §1.2).WrongProjectError.safe_messagereturns the generic"Issue ID does not belong to this project". HTTP and MCP error envelopes use that string; CLI / stderr /filigree doctorkeep the richstr(exc)form with the offending and open prefixes intact for diagnostics. Untrusted callers can no longer probe for project membership by pattern-matching foreign-ID error bodies. -
Server-mode read endpoints block cross-project IDs at the route boundary (2.1.0 §1.3). A new
_check_read_prefix_in_server_modehelper short-circuits every read endpoint that accepts anissue_idpath parameter (GET /api/issue/{id}and family, classic + loom;GET /api/issue/{id}/entity-associations) with 404/safe_message when the dashboard is running in multi-project server mode and the requested ID has a foreign prefix. Ethereal (single-project) mode preserves the documented read-tolerance ofdb.get_issuefor foreign IDs so jsonl import, migration, andfiligree doctorkeep working. The data-layerdb.get_issueis unchanged — enforcement lives at the HTTP boundary where cross-project probing is the meaningful attack surface. -
sanitize_actorlength cap pinned at every entry point (2.1.0 §1.4 / ADR-012). Newdocs/architecture/decisions/ADR-012-actor-identity-threat-model.mddocuments what actor strings are (claims, not proofs) and what the 2.1.0 hardening pass enforces (128-char cap, control-char rejection, non-empty after strip) at every entry point: CLI (tests/cli/test_compose_commands.py), MCP (tests/mcp/test_boundary_validation.py), and HTTP (tests/api/test_api.py::TestActorLengthCapAtHTTPBoundary). Transport-bound identity verification — the "verified actor" enhancement — is tracked as a 2.3.0+ work package in the filigree tracker. -
Batch handlers abort envelope-level on foreign-prefix IDs (2.1.0 §0.4). Every batch handler in the data layer (
batch_close,batch_update,batch_add_label,batch_remove_label,batch_add_comment) now pre-flights everyissue_ids[i]through_check_id_prefixbefore any per-item write commits; a foreign prefix raisesWrongProjectErrorenvelope-level instead of being silently re-classified as N per-item validation failures. CLI batch commands (filigree batch-update,batch-close,batch-add-label,batch-remove-label,batch-add-comment), the dashboard'sPOST /api/batch/{update,close}(classic + loom envelopes), and the MCPbatch_*tools all translate this to a single envelope- level VALIDATION error rather than crashing or emitting per-item noise. Closes the silent foreign-DB-mutation surface flagged as silent-failure H7 in the 2.1.0 panel review — the same class of cross-project confusion that PR #41'score.pyanchor-discovery hardening addressed on the read side. -
Briefing-blocked files bypass the Clarion fallback wrapper (Clarion 1.0 CONTRACT-3). Clarion 1.0 returns HTTP 403 with body
{"code": "BRIEFING_BLOCKED", ...}for files it intentionally withholds (secret-bearing, owner-locked).ClarionRegistrymaps that response to the newRegistryBriefingBlockedError, which subclassesRegistryResolutionError— deliberately notRegistryUnavailableError— so the_ClarionLocalFallbackRegistrywrapper does not engage. Without this distinction, a briefing-blocked secret-bearing file would be silently re-attached under a localfile_idand re-enter Filigree's index outside Clarion's access controls. A newErrorCode.BRIEFING_BLOCKEDmaps to HTTP 403 inerrorcode_to_http_status, and the batch resolver exposes a dedicatedbriefing_blockedchannel so call sites distinguish refusal from absence (not_found) and transport failure (errors/RegistryUnavailableError). The end-to-end audit is pinned intests/api/test_registry_backend_integration.py(briefing-blocked viaPOST /api/loom/scan-results: no row created, no fallback event emitted) and the wire-shape tests intests/unit/test_registry.py.
2.0.3 - 2026-05-17
- Project discovery now recognises git linked worktrees. Running
filigree <verb>from inside a worktree (e.g.repo/.worktrees/feature/) previously raisedForeignDatabaseErrorbecause the worktree's.gitfile — a pointer to<main_repo>/.git/worktrees/<name>/— was treated as a separate-project boundary by the walk-up guard. Discovery now parses worktree pointers and redirects to the main worktree root, so the CLI Just Works from any worktree of a filigree-tracked project. Submodules (.gitfile pointing at.git/modules/<name>) remain a boundary as before; plain repos with.gitas a directory are unaffected. The previously-recommended workaround(cd "$(git rev-parse --git-common-dir)/.." && filigree …)is no longer necessary.
2.0.2 - 2026-05-16
close_issueno longer auto-resolves the target status to a single reachable done state. Previously, when the template default done state was unreachable from the current status but exactly one done-category state was reachable,close_issue(andbatch_close) silently picked that target and emitted adata_warning. This hid intent: afeatureinbuildingthat had actually shipped becamedeferred(semantically "abandoned") on close, becausedeferredwas the only reachable done-state. Callers must now either passstatus=explicitly to choose a dismissal target, walk the workflow forward to a state from which the template default is reachable, or passforce=Trueto bypass the transition validator. Failed closes echovalid_transitionsso a caller can synthesise the retry. Affectsclose_issueandbatch_closeon both the CLI and MCP surfaces.
- HTTP batch-close accepts
force.POST /api/batch/closeandPOST /api/loom/batch/closenow accept an optionalforceboolean in the request body, matching the CLI--forceflag and MCPforceargument. This gives HTTP callers a recovery path when the close auto-resolve removal would otherwise leave them with no way to close planning items (phase/step/milestone) in their initial states.
2.0.1 - 2026-05-15
- Agent instruction prompt restructured. The CLAUDE.md / AGENTS.md block
that
filigree installinjects has been rewritten from a 231-line man-page-style catalogue to an 84-line behavioural prompt. The workflow loop with a worked example, the observation-scope policy, and the priority enum are now top-level; the full CLI Quick Reference, response-shape prose, federation generation essay, and the eagerly pre-loadedForeignDatabaseErrorandSCHEMA_MISMATCHwalkthroughs have been dropped in favour offiligree --help, MCP tool schemas, and short recovery pointers at the bottom. The rationale for preferringstart_workover the racyclaim_issue + update --status=in_progresspair is now stated explicitly, andINVALID_TRANSITIONrecovery points the agent atget_valid_transitions. The instructions hash changes, sofiligree installandfiligree doctorwill refresh downstream CLAUDE.md and AGENTS.md files on next run.
2.0.0 - 2026-05-14
-
Bundled scanner activation and prompt-pack review lenses. Filigree now ships packaged scanner runner entrypoints (
filigree-scanner-codexandfiligree-scanner-claude) plusfiligree scanner available/prompts/enable/ disableand matching MCP management tools so projects opt into scanners by writing managed.filigree/scanners/*.tomlregistrations instead of copying repo-local scripts. Scanner trigger/preview flows resolve the active local dashboard callback port, expose callback provenance (api_url_source), advertise prompt support and risk/sandbox metadata, and reject non-default prompt packs for custom scanners that do not declare a{prompt}template placeholder.filigree doctornow nudges projects with zero scanners, flags stale bundled scanner registrations with exact--forceremediation, and reports enabled bundled scanners whose runner command is missing. Bundled prompt packs now expose full injected instructions,when_to_use,audience,language, advisory scope, and relative-cost hints and cover security, quality engineering, solution architecture, systems thinking, system interactions, Python, PyTorch, CSS, JavaScript, TypeScript, React, Rust, Go, Terraform, SQL,major-refactor, and broadercomprehensivereviews. Scanner records now includeprompt_pack_awareandapplicable_prompts, andlist_prompt_packs/filigree scanner promptscan be filtered by language. A bundled-but-not-enabled scanner request now returns an actionable hint pointing atlist_available_scanners/enable_scanner(or the CLIfiligree scanner available/filigree scanner enableflow), and the CLI includes verb-noun aliases for scanner management (list-available-scanners,enable-scanner,disable-scanner, andlist-prompt-packs). -
Senior-user MCP review closure package. The review corpus is now reconciled into a closed master checklist with implementation evidence, ADRs, and docs for the locked 2.0 outcomes. New ADRs record that Filigree's persisted records are durable for utility but not an audit-proof product; schema mismatches fail closed; workflow enforcement stays soft where templates define it; unknown MCP parameters are rejected;
report_findingobservation creation is explicit; claim-aware writes default to the actor; response shapes prefer slim defaults plus explicit detail controls; archived issues are done-category history; and first-class agent session/run checkpoints are deferred beyond 2.0. -
Shared file annotations. Files can now carry durable annotations with anchors, severity/intent, link relationships, supersession, promotion, attention queries, and carry-forward workflow for critical
must_considerannotations. Core, MCP, and CLI surfaces are covered by regression tests, and carry-forward now validates that the annotation is actively linked to the source issue before acknowledging it. -
Structured observation triage. Observations can be linked to existing issues as evidence, duplicates, superseded notes, or related context via
link_observation/batch_link_observations; multiple observations can be promoted into one issue while preserving source IDs. Observation listing also gained richer filters for actor, source issue, priority, age, and sorting so agents can clean up session residue without losing evidence. -
Agent-oriented discovery and cleanup tools.
get_schemaderivesaccepted_by_toolsfrom the live MCP registry;get_blockedandget-blockedcan hydrate slimblockers[]context;get_changes/get-changesshare actor, issue, label, type, cursor, and heartbeat controls;get_stale_claimscan include near-expiry leases;add_commentreturns a structured comment echo; and the CLI/MCP docs now include one mixed scratch cleanup recipe spanning claims, observations, findings, file records, scratch archive, and event compaction. -
get_summaryacceptsformat='json'to return a structured envelope{markdown: <str>, stats: <get_stats output>}so callers doing programmatic orientation don't need a follow-upget_statscall. Default behaviour (format='markdown') is unchanged. (filigree-cb980eee0d, P3.12) -
dismiss_findingacceptsstatusto record an alternate dismissal status (fixed,unseen_in_latest,acknowledged) rather than always writingfalse_positive. The default remainsfalse_positivefor backwards compatibility. The natural verb no longer forces the wrong status name when the dismissal is "won't fix here" or "no longer present". (filigree-cb980eee0d, P3.13) -
archive_closedrequires a non-emptylabelfilter whendays_old<7to prevent accidentally sweeping up issues closed minutes ago across the whole project. The error message points at the fix (label='cluster:<name>'). The CLI command is unchanged — the guard lives at the MCP layer where agents could trigger over-broad sweeps. (filigree-cb980eee0d, P3.17) -
get_blockednow includes wip-category issues that are stuck on another non-done issue. Previouslyget_blockedfiltered to open-category only, so an in-progress task waiting on an open blocker was invisible to the "what's stuck right now" surface. filigree-cb980eee0d, P2.8 senior-user MCP review. -
add_labelreports mutual-exclusivity displacement. When areview:namespace label silently displaces a sibling, the response now carrieslabel_result="replaced",replaced_labels=[...], and adata_warningsentry naming the displaced label. Previously the displacement was completely silent and the response showedlabel_result="added". CLI emits a "(replaced: ...)" hint.add_labelnow returns a 3-tuple(added, canonical, replaced)internally; the only callers were within the codebase. (filigree-cb980eee0d, P2.7) -
PublicIssueemits bothparent_idandparent_issue_id.get_issuepreviously returnedparent_idwhileget_readyandlist_issuesreturnedparent_issue_id— the same value with two names depending on the tool. Both names now appear on the full PublicIssue shape; agents should preferparent_issue_idfor consistency.parent_idis retained for backwards compatibility. (filigree-cb980eee0d, P2.9) -
get_changesdefaults to excludingheartbeatevents. With 48-hour leases and aggressive heartbeating, the catch-up firehose was dominated by liveness pings. The MCP tool now filters outheartbeatevents by default; passinclude_heartbeats=true(ortype='heartbeat'explicitly) to see them.get_events_sinceindb_eventsgains anexclude_types: list[str] | Noneparam. (filigree-cb980eee0d, P2.11) -
heartbeat_workdefaults the audit actor to the issue's current assignee when the caller omitsactor. Previously the MCP handler defaulted to the literal string'mcp', so the rightful holder's heartbeat would CONFLICT with "expected 'mcp'" whenever they forgot to passactor— silently letting their lease expire. (filigree-cb980eee0d, P2.5) -
Observations created from findings link back via
source_finding_id;dismiss_findingandpromote_findingcascade-dismiss the link. Senior-user MCP review (run d, finding P1.2) flagged that the prior fix (9af7b82) only added the observation IDs to thereport_findingresponse — the observations themselves had no structural backlink, so closing or promoting the finding left a 14-day zombie observation that re-appeared inlist_observationsevery time. The schema now addsobservations.source_finding_id(migration v11→v12) and an index for cascade scans;report_findingpopulates the field, andupdate_finding(the underlying helper for bothdismiss_findingandpromote_finding) auto-dismisses any linked observation when the finding reaches a terminal status (false_positive,fixed,unseen_in_latest) or is linked to an issue. The dismissal is recorded indismissed_observationsfor audit. (filigree-cb980eee0d) -
release_claimauto-reverts wip→open by default. Senior-user MCP review (run d, finding P1.3) flagged that releasing a claim while the issue was in a wip-category status (e.g.in_progress,fixing) cleared the assignee but left the status unchanged, so the issue became invisible toget_ready,get_blocked,get_summary— orphaned in wip with no discovery surface.release_claimnow resolves a template-defined reverse target (the open-category predecessor whose forward transition targets the current wip status; e.g.in_progress→openfortask,fixing→confirmedforbug) and transitions the status back so the issue rejoinsget_ready. Statuses with no direct open predecessor (e.g.bug.verifying) fall back to the template'sinitial_stateif it is open-category. Passrelease_claim(revert_status=False)to opt out and keep the legacy "release without status change" behaviour. (filigree-cb980eee0d) -
Optional
expected_assigneeprecondition on write tools. Senior-user MCP review (run d, finding P1.1) flagged thatheartbeat_work/release_claim/reclaim_issuestrictly enforce claim ownership whileupdate_issue/batch_update/close_issue/add_comment/add_label/remove_labelignored it — a non-claimant could overwrite a held issue silently. Each of those tools now accepts an optionalexpected_assigneestring; when set, the call returnsCONFLICT(with the CONFLICT-shaped"assigned to '<x>' (expected '<y>')"message) if the observed assignee differs. Default behaviour with the parameter omitted is unchanged for backward compatibility. Batch tools apply the precondition per-item and surface failures via the standardfailed[]array. (filigree-cb980eee0d)
-
report_findingno longer creates observations by default. MCP callers must passcreate_observation=true, and CLI callers must pass--create-observation, to create a paired triage observation. Default responses stay slim and single-finding oriented; full/paired observation details are available only when explicitly requested. -
Claim-aware writes default the expected holder to the actor. When a held issue is updated, closed, commented, labelled, or batch-mutated with an
actor/author but no explicitexpected_assignee, the current holder must match that actor. Coordinator flows can still passexpected_assigneeto edit another actor's held issue deliberately. -
Workflow, archive, and relationship semantics are documented as runtime contracts. Public docs now define issue ID vocabulary (
issue_id,parent_issue_id,from_issue_id,to_issue_id), template status categories, soft warnings, close/reopen behavior, close/dismiss reasons, label-scoped archive doctrine, and optional requirements-pack issue types. -
search_issuesdescription now documents FTS tokenisation behaviour. Hyphens split tokens and tokens shorter than 2 chars are elided, somcp-review-dreturns nothing whilemcp reviewmatches[mcp-review-d] Scratch task A. (filigree-cb980eee0d, P2.6)
-
MCP tools reject unknown parameters before handler dispatch. Extra keys now return a
VALIDATIONerror naming the unsupported parameter(s) instead of being silently ignored. This makes misspelled or hallucinated tool arguments fail early and keeps runtime behavior aligned with the published schemas. -
close_issuenow validates state transitions likeupdate_issue. The senior-user MCP review (run d,docs/plans/2026-05-06-mcp-senior-user-review-d.mdfinding P1.4) flagged thatupdate_issue(status='closed')andclose_issue()had opposite contracts for the same target state — a bug stuck intriagecould be force-closed viaclose_issueeven though the bug template only allowstriage → wont_fix,triage → not_a_bug, or walking throughconfirmed → fixing → verifying → closed.close_issuepreviously passed_skip_transition_check=Trueto the underlying update; that bypass is now removed. The MCP/CLI/dashboard surfaces all return the sameINVALID_TRANSITIONenvelope (withvalid_transitionsandhint) for unreachable target statuses. To support legitimate alternate-done closures,close_issuenow exposesstatus(already in the underlying db method, but absent from MCP/CLI/dashboard surfaces); passclose_issue(status='wont_fix')/filigree close <id> --status=wont_fixto land in a directly reachable done state when the default isn't reachable. The MCP tool description and CLI help text document this. (filigree-cb980eee0d)
-
response_detail/--detailopt-in on MCP and CLI batch tools. Closes a documentation/implementation gap: the agent guidance (src/filigree/data/instructions.md, the projectCLAUDE.mdandAGENTS.mdblocks, and thefiligree-workflowskill pack) promisedresponse_detail="full"on every batch tool but only the dashboardloomHTTP routes implemented it; MCP/CLI silently ignored the parameter. The flag is now wired on the six batch tools across all three surfaces:- Issues:
batch_close/batch-closeandbatch_update/batch-update— slimBatchResponse[SlimIssue](default) → fullBatchResponse[PublicIssue]. - Meta:
batch_add_label/batch-add-labelandbatch_add_comment/batch-add-comment— slimBatchResponse[str](issue IDs) → fullBatchResponse[PublicIssue]. - Findings:
batch_update_findings/batch-update-findings— slimBatchResponse[str](finding IDs) → fullBatchResponse[ScanFindingDict]. - Observations:
batch_dismiss_observations/batch-dismiss-observations— slimBatchResponse[str](observation IDs) → fullBatchResponse[ObservationDict](records snapshotted before dismissal so callers can audit what they just dropped). Backed by a newFiligreeDB.get_observations_by_idshelper.
Bad
response_detailvalues returncode: VALIDATION(MCP) or exit 2 (CLI usage error). The sharedparse_response_detailhelper infiligree.types.apiis the single source of truth for the slim/full vocabulary across MCP, CLI, and dashboard routes. - Issues:
-
CLI
get-template <type>verb-noun alias. Mirrors the MCPget_templatetool and the existing pattern (get-type-info,get-valid-transitions,get-workflow-guide). Supports--jsonand emits the 2.0 flat error envelope (ErrorCode.NOT_FOUND) on unknown types. (filigree-6213766f9b) -
CLI
get-workflow-statusesverb-noun alias. Closes a hole in the verb-noun alias convention (docs/cli.md): the MCP tool isget_workflow_statusesbut only the short formworkflow-statuseswas registered. Both forms now produce identical output; the alias table indocs/cli.mdis updated. (filigree-ca938979f4)
-
Schema-mismatch diagnostics now identify the executing runtime. Warm degraded MCP status includes schema versions, project path, Python executable, resolved executable, entrypoint, module file, package root, venv root, and install context. Normal MCP tool calls continue to fail closed with
SCHEMA_MISMATCHwhileget_mcp_statusremains available. -
Response envelopes and slim/full detail controls are normalized across late MCP/CLI gaps.
get_valid_transitionsnow returns a list envelope, plan reads default slim and accept full detail, batch response detail behavior is pinned, and plan-move warnings surface carried dependencies so callers can retarget blockers intentionally. -
File/finding actor attribution is durable. Schema v14 records
created_by/updated_byon file records and scan findings and stores actor identity on file associations and metadata timeline events. Register, update, dismiss, promote, report, and batch finding paths preserve actor identity through public records and file timelines. -
Senior-user review hardening closed late interface and boundary bugs. The post-review sweep tightened issue/observation CLI JSON shapes, loom change cursor semantics, loom issue route contracts, scan-result cleanup, scan log tail handling, dashboard config failures, project config parsing, JSONL metadata import, release summary listing, semver parsing, workflow enum validation, annotation anchor ranges, observation replacement atomicity, and finding CLI context refresh.
-
Loom
ScanIngestResponseLoomis now a concrete TypedDict. It previously subclassedBatchResponse[str]fromfiligree.types.api, but at runtime TypedDict +Genericdoes not preserve thestrsubstitution (succeededresolved tolist[~_T]), and parentNotRequiredmarkers are stripped on atotal=Truesubclass — which promotednewly_unblockedto a required key on the response type, even though scan ingestion cannot unblock issues. The shape is now declared inline (succeeded: list[str],failed,stats,warnings); aget_type_hintscontract test pins it. (filigree-58f82a0c38) -
Loom
BatchCloseResponseLoom.succeededwidened tolist[SlimIssueLoom | IssueLoom]. The C5 contract (docs/federation/contracts.md§C5) and the handler indashboard_routes/issues.pyupgradesucceeded[]to fullIssueLoomitems whenresponse_detail=full, but the type was pinned slim-only.newly_unblockedstaysSlimIssueLoomper the locked C5 rule. Aget_type_hintscontract test pins both. (filigree-2189802389) -
filigree installandfiligree doctor --fixnow surfaceForeignDatabaseErrorinstead of swallowing it as a generic missing-project error. Both admin commands caught the bareFileNotFoundErrorraised byfind_filigree_root, so the richForeignDatabaseErrormessage ("Refusing to latch onto another project's filigree database…cd <git boundary> && filigree init") never reached users running these commands from inside an inner repo. The catch is nowProjectNotInitialisedError(the parent class of bothProjectNotInitialisedErrorandForeignDatabaseError), and the exception's own message is emitted to stderr — same contract ascli_commands/scanners.pyand the session-hook fix above. (filigree-dad647cf35) -
filigree installnow exits 1 when one or more selected install steps failed. The command accumulated per-step(name, ok, msg)tuples and printed them, but never mapped anyok=Falseto a non-zero exit code, so CI pipelines and shell scripts depending on the exit code treated partial failures as success. The summary now exits 1 (with a "Some install steps failed" stderr line and a suppressed "Next:" hint) whenever any step returnedFalse.TestInstallModetests gained the sameSERVER_CONFIG_DIRisolation thatTestInstallModeIntegrationalready used, since the exit-code change exposed pre-existing prefix-collision pollution from the user's real~/.config/filigree/server.json. (filigree-ca4e5d28dd) -
filigree metrics --daysnow rejects non-positive values via aclick.IntRange(1, 3650). Without the range,--days=-5reachedget_flow_metrics's ownValueErrorand bubbled up as a Python traceback. The range mirrors sibling admin commands (archive,clean-stale-findings,compact) and the dashboard's ownapi_metricsclamp at[1, 3650]. (filigree-d9cf9d34b1) -
filigree exportnow catchesOSError/sqlite3.Errorand emits a clean "Export failed: …" line. The command calleddb.export_jsonlunguarded, so missing parent directories, permission errors, or disk-full conditions surfaced as raw Python tracebacks. The matchingimportcommand already had this contract (and a regression test);exportnow mirrors it. (filigree-48613c1c55) -
Session hooks and dashboard launcher honour
.filigree.confand surfaceForeignDatabaseError.generate_session_contextwalked up viafind_filigree_rootand then opened.filigree/filigree.dbdirectly, ignoring any customdbpath declared in.filigree.conf;ForeignDatabaseError(raised when walk-up crosses a.git/boundary) was swallowed by the catch-allFileNotFoundErrorhandler, hiding the actionable remediation message. Both session-context andensure_dashboard_runningnow resolve viafind_filigree_anchor/find_filigree_root, route DB init throughFiligreeDB.from_confwhen a conf is present, and surface the foreign-database message instead of exiting silently. (filigree-4e28325279, filigree-acbacc5b3e) -
Dashboard URL emitted from session context now uses
verify_pid_ownership. The pre-existing fast path in_build_contexttrusted rawis_pid_alive, so a recycled PID owned by an unrelated process could be reported as a live dashboard. Session context now performs the same OS-cmdline identity check that the reuse path andfiligree doctoralready use. (filigree-aa38935c28) -
Ethereal dashboard reuse no longer races concurrent starts. The pre-lock probe in
_ensure_dashboard_ethereal_modeunlinkedephemeral.pid/ephemeral.portwhen ownership verification failed, so a peer that wrote fresh metadata between our read and our verify could have it erased outsideephemeral.lock. The probe is now read-only outside the lock; all destructive cleanup runs after the lock is held. The post-lock cleanup also terminates an owned dashboard whose port never came up past the startup grace window before respawning, so the orphan process is no longer leaked. (filigree-48215e8343, filigree-fd3ac0feec) -
Skill-pack freshness check now hashes the entire skill tree. The hook compared only
SKILL.mdbetween source and installed copies, so changes to companion files underexamples/andreferences/could leave installed skill packs stale indefinitely. The freshness check now computes a sorted(relative path, bytes)digest across the wholefiligree-workflowdirectory and reinstalls when any file differs. (filigree-4bd71d94c3) -
CLI
--priority*ranges now emit the 2.0 envelope instead of Click usage errors.list,list-issues,update,update-issue,claim-next, andstart-next-workdeclared bounded numeric options asclick.IntRange(0, 4), which made Click reject out-of-range values with aBadParameterplain-text usage error before the command body could emit{"error", "code"}(Phase E §9 envelope contract). The_range_check_prioritybody-time helper thatcreatealready used for the same reason is generalised to_range_check_int(value, name, ...), and the affected options now parse as plainintand validate after--jsonhas been observed. (filigree-ff324e6a96) -
CLI
claim-next --jsonnow emits the documentedClaimNextResponse/ClaimNextEmptyResponseshapes. Empty replies gainreason: "No ready issues matching filters"; successful replies gainselection_reasonbuilt from the new sharedIssue.format_claim_next_reason()helper, so CLI / MCP / HTTP all agree on the wire shape (the MCP handler is refactored to use the same helper to keep parity by construction). (filigree-0e8dadbfcc) -
/api/loom/scannersnow resolves scanner TOMLs from the project root, not the DB directory. The route builtscanners_dir = db.db_path.parent / "scanners"(src/filigree/dashboard_routes/files.py), which only matched the legacy.filigree/filigree.dblayout. For.filigree.confprojects with a relocateddb = "data/track.db", the route looked at<project>/data/scannersinstead of<project>/.filigree/scannersand returned an empty list — even though the CLIlist-scannersand MCPlist_scannerscorrectly enumerated them. Same family as filigree-da8d5aba0f (dashboard opens conf-declared DB path). The route now anchors todb.project_root / ".filigree" / "scanners"whenproject_rootis set (always the case forfrom_filigree_dirandfrom_conf), and falls back todb.db_path.parent / "scanners"only for bareFiligreeDB(...)construction. Regression tests intests/test_dashboard.py::TestLoomScannersRelocatedDB. (filigree-641037692a) -
Dashboard now opens the conf-declared DB path. Both ethereal mode (
main()) and server mode (ProjectStore.get_db) calledFiligreeDB.from_filigree_dir, which hardcodes.filigree/filigree.dband ignores thedbfield in.filigree.conf. Custom-DB projects (e.g.db = "storage/track.db") were silently displayed and mutated through the wrong (default) database, while the CLI/MCP — which goes throughcli_common.py— opened the correct path. A new_open_db_for_filigree_dirhelper now usesfrom_confwhen a.filigree.confsits next to the directory and falls back tofrom_filigree_dironly for legacy installs (the same shape ascli_common.py:_build_dband the doctor fix in filigree-3572d3b273). Regression test intests/api/test_multi_project.py::TestProjectStore::test_get_db_honors_custom_db_path_from_conf. (filigree-da8d5aba0f) -
Dashboard
ProjectStore.reload()is now atomic and serves consistent handles under concurrent reads. Two race windows existed: (1)load()published_projectsbeforereload()evicted stale_dbsunder the lock, leaving a window where_projects[key]referenced the new path while_dbs[key]was the old handle (request handlers got the wrong DB); (2)get_db()had an unlocked fast path that could hand out a handle immediately beforereload()popped and closed it (request handlers got a closedsqlite3.Connection). The fix factorsload()into a_compute_projects()helper, performs an atomic state swap inreload()under one lock acquisition, removes the unlocked fast path inget_db(), and parks evicted handles on_evicted_dbsso they are closed only atclose_all()(process shutdown) — sidestepping the close-while-in-use hazard without introducing a refcount/lease mechanism. New regression testtests/api/test_multi_project.py::TestProjectStore::test_reload_atomic_under_concurrent_get_dbexercises a reader against a path-rotating reloader and asserts no torn views and no closed handles. (filigree-e43edbc067) -
report-finding --filenow surfaces the foreign-database diagnostic. The command read and validated the--filepayload before resolving the filigree project, so aForeignDatabaseError(cwd inside a git repo whose nearest filigree anchor sits across a.git/boundary) was masked by the next available file/JSONVALIDATIONenvelope. Project resolution now runs at the top of the callback via_resolve_filigree_dir_or_die, matching every other command in this module. TheTestForeignDatabaseDiagnosticmatrix intests/cli/test_scanners_commands.pygains areport-finding --file <missing> --jsonparametrize case. (filigree-08ed302942) -
trigger-scan-batch <scanner> --jsonnow returns a structured envelope on empty file lists. The variadicfile_pathsargument was declaredrequired=True, so Click rejected an empty invocation with raw usage text before the callback ran — making the in-callbackVALIDATIONenvelope (and its match with the MCPmcp_tools/scanners.pycontract) unreachable from the CLI. The argument is nowrequired=False; the existing empty-list guard emits{"error": "file_paths must be a non-empty list", "code": "VALIDATION"}as intended. New regression test intests/cli/test_scanners_commands.py::TestForeignDatabaseDiagnostic::test_trigger_scan_batch_empty_filepaths_returns_validation_envelope. (filigree-9b04b0aca9) -
Graph v2: archived issues no longer leak through
include_done=falseandstatus_categoriesfilters.archive_closed()writesstatus='archived'while preservingclosed_at, but the synthetic status resolves to categoryopen, notdone. The graph route filtered only onstatus_category == 'done', so archived issues appeared ininclude_done=falseresults, matchedstatus_categories=open, and inflatedblocks_open_count(the.blocksadjacency list, unlike.blocked_by, isn't pre-filtered for archived members). A new_graph_status_category()helper normalizes archived todonefor filter predicates, blocker/dependent counts, and the serialized node payload. (filigree-b6cacfce72) -
Graph v2: shortcut edges between non-adjacent critical-path nodes are no longer flagged
is_critical_path.db.get_critical_path()returns an ordered chain (longest-path DP). The route collapsed it into a node-id set and marked any edge between two members critical, so a chainA→B→C→Dplus shortcutA→Dmis-flagged the shortcut even though it isn't part of the chain. The route now keeps the ordered list and builds adjacent(source, target)pairs;_filter_graph_edgesflags only those pairs. (filigree-c9b08d1363) -
install_codex_mcp: replace TOML-equivalent header forms in place instead of duplicating them._upsert_toml_tablematched the existingmcp_servers.filigreeblock with a literal regex (^\[mcp_servers\.filigree\]), so valid equivalent forms accepted bytomllib—[mcp_servers."filigree"],[mcp_servers . filigree],["mcp_servers".filigree]— bypassed replacement and took the append branch. The resulting file then contained two semantically identical tables, whichtomllibrejects under its duplicate-table rule, breaking subsequentinstall_codex_mcpruns with "malformed TOML; fix or remove it." The matcher now parses each^\[...\]header throughtomlliband compares the normalized key path, so any TOML-equivalent spelling is replaced in place. (filigree-32fe96222e) -
_install_skill_to: concurrent installs no longer race on a shared staging directory. The old implementation reused a deterministic<skill>.installingsibling path for every invocation; two Claude Code sessions starting near-simultaneously (each firing the SessionStart stale-skill auto-refresh inhooks.py) could clobber each other's staging area, producingFileExistsErroror leaving the target directory absent between rmtree and rename. Each call now stages into a uniquetempfile.mkdtempdirectory and the swap tolerates a peer winning the rename race (their staged content is identical). (filigree-86b73c3d6c) -
_atomic_write_text: preserves destination permissions on rewrite.tempfile.mkstemp()creates files with mode 0o600; the previous implementation'sos.replace()would silently downgrade existing files (CLAUDE.md, AGENTS.md, .gitignore) from 0o644 to 0o600 on every install or update. The helper now captures the existing mode before the swap and chmods the temp file to match. New files respect the process umask. (filigree-156023f053) -
Doctor:
.gitignorecheck now uses the same gitignore-aware parser asensure_gitignore, instead of substring matching. Previously a.gitignorecontaining only a comment (# .filigree/ ...), only a negation (!.filigree/), or only a non-root substring (src/.filigree/cache/) would be reported as healthy even though git did not actually ignore the project-root.filigree/. The parser was extracted intoinstall_support/gitignore.pyso install and doctor share one implementation. (filigree-bc5d2af1ef) -
Doctor: malformed
mcpServers.filigreeentries in.mcp.jsonare now flagged invalid. A truthy non-dict value (string, bool, list) used to silently report "Configured in .mcp.json" because the per-server schema was never validated —commandwas coerced to"", both absolute-path branches were skipped, and the success branch caught the fall-through. Doctor now requires the entry to be a dict and validates the stdio (non-emptycommand, listargs) and streamable-http (non-emptyurl) shapes the installer emits. (filigree-466bcb6279) -
Doctor no longer subprocess-runs the hook-configured Python interpreter. When the SessionStart hook in
.claude/settings.jsonused module form (<abs-path> -m filigree …),filigree doctorused to execute<abs-path> -c "import filigree"to detect a venv-purged install (filigree-36539914b3). Because.claude/settings.jsonis project-controlled, a hostile or compromised repo could plant a binary at that path and get arbitrary code executed under anyone runningfiligree doctoron a fresh clone. The probe is removed; module-form hooks now get structural validation only. The original venv-purge case still surfaces as a SessionStart failure on the next session and is repaired byfiligree install --hooks. (filigree-e6828dcdb1) -
Scan ingest:
process_scan_resultsrejectssuggestion=NonewithValueErrorinstead of crashing withTypeError. The validator in_validate_scan_findings(db_files.py:422) only rejected non-string suggestions when the value was notNone, but the write path (_upsert_finding) calledlen(suggestion)unconditionally viaf.get("suggestion", "")— anddict.getreturns the explicitNone, not the default. The HTTP routes only translateValueError→ 400, so anullsuggestion became a 500. The validator now treatsNonethe same as any other non-string value. (filigree-e74fecddd4) -
Scan ingest:
finding.languageis validated. The validator hardened every documented finding field exceptlanguage. A non-string value either silently coerced to a string (SQLite type affinity onint) — violating theFileRecordDict.language: strcontract — or leaked a rawsqlite3.ProgrammingErrorto the API consumer (onlist/dict).languageis now required to be a string when present;Noneis normalized to""for symmetry with the existingf.get("language", "")default. (filigree-2134b23fb9) -
scan_runs.findings_countreflects findings observed by this run, not first-attribution membership.scan_findings.scan_run_idis first-attribution-wins (set on insert, preserved on update by_update_existing_finding). The completion path counted viaSELECT COUNT(*) FROM scan_findings WHERE scan_run_id = ?, so a re-scan that only re-saw existing findings reportedfindings_count=0. The count is now accumulated incrementally on thescan_runsrow per batch (findings_created + findings_updated), which fixes both the re-scan case AND preserves the existing multi-batch contract where the orchestrator's finalcomplete_scan_run=Truecall has empty findings. (filigree-f84f141e86) -
list_files_paginatedrejects invalid sortdirection. The rest of the query-shaping params raiseValueErrorfor unknown values, butdirectionsilently fell back to the default order:direction='SIDEWAYS'returned a default-ordered result instead of erroring. MCP's wrapper already validates strictly — this brings the core API in line, so HTTP/CLI callers get the same 400 the MCP surface returns.Nonestill means "use default." (filigree-e53ce98110) -
setup_logging()always normalizes the surviving handler. If another import attached a bareRotatingFileHandlerfor the target path beforesetup_logging()ran, the dedup loop reused that handler and returned early — leavinglogger.levelatNOTSET(effectiveWARNING) and the formatter unset, so MCP startup INFO events were silently dropped and warning/error records were written as plain text instead of JSONL. The function now applies_JsonFormatterandlogger.setLevel(logging.INFO)unconditionally on the surviving handler. Regression test pre-attaches an unconfigured matching handler and asserts the level/formatter and that aninfoemit lands as a single JSON line. (filigree-1ba7d648c6) -
batch-update-findings --jsonall-failed envelope no longer misclassifies storage failures asVALIDATION. The per-item loop already classifies failures asNOT_FOUND/VALIDATION/IO, but the all-failed branch hard-codedErrorCode.VALIDATIONfor the envelope, breaking the closed-set contract callers branch on for retry policy: ansqlite3.Errorburst was reported as a client-input error. The envelope now derives its code from the per-item codes —IOwins (retryable); a single homogeneous code is preserved; only genuinely mixed failures fall back toVALIDATION. Regression monkeypatchesupdate_findingto raisesqlite3.OperationalErrorand assertscode == "IO". (filigree-c2aeba2946) -
add_dependency()no longer leaks an open SQLite write transaction on the duplicate path. The idempotentINSERT OR IGNOREopens an implicit write transaction even when no row changes; the earlyreturn Falseskipped bothcommit()androllback(), so the lock lingered and any other connection's write failed withdatabase is locked. Now the duplicate path explicitly rolls back before returning. Regression coversconn.in_transactionand a second-connection write. (filigree-a0fc2b4ecc) -
get_plan()andget_release_tree()no longer silently truncate after 100 children. Both delegated tolist_issues(parent_id=...), which defaults tolimit=100and always appliesLIMIT ? OFFSET ?; plans/releases with more than 100 children lost the rest from the tree and fromtotal_steps/progress. Tree builders now use a private_list_all_children()that issues an unbounded child query. Regression covers 101 phases, 101 steps, and 101 release children. (filigree-07d55ee5e5) -
create_plan()rejects float and other non-int/non-strdep_refvalues instead of silently misrouting. Parsing coerced every ref throughstr(dep_ref), so0.1became"0.1"→phase 0 step 1— a real dependency on the wrong step instead of aValueError. Booleans and oddly-formatted strings ("+0"," 1 ") followed the same path. A new_normalize_dep_refvalidator runs first: acceptsint(excludingbool, non-negative) andstrmatching"N"or"P.S"with non-negative integer parts; rejects everything else withValueError. (filigree-6802ed02e0) -
verify_pid_ownershipPID-file fallback no longer rejects the dashboard's own record when the port was stored as a separate metadata field. When the OS-argv lookup is unavailable (constrained sandboxes without/proc/ps/wmic), the fallback re-validated againstcmdplus the appended--port <N>requirement — but callers (hooks.py:420,server.py:269/393) recordcmd="filigree dashboard"and the port in a separateportfield, so the requirement could never match and live dashboards were misclassified as stale. The fallback now validatescmdagainst caller-suppliedrequired_argsonly, trusting the parsedportfield as metadata. The strict--port <N>match is retained on the OS-argv path, where the live argv actually carries the token. (filigree-403dd029c3) -
cleanup_stale_pidno longer clobbers a newer primary PID file on POSIX during quarantine restore. The restore path usedPath.rename(quarantine, pid_file), which on POSIX overwrites the destination — the comment claimed the rename would fail when a fresh primary already existed. Combined with a shared.removingquarantine name, two concurrent cleaners could collide and overwrite each other, and a newer writer's PID record could be replaced by an older quarantined copy. The quarantine filename is now unique per cleaner (suffix +os.getpid()+time.monotonic_ns()), and the restore usesos.link, which atomically fails withFileExistsErrorif the destination already exists; the quarantined copy is dropped in that case so the newer primary stands. (filigree-5aa3dc590a) -
read_pid_fileno longer truncates float PIDs/ports or leaksOverflowErroron non-finite JSON.int(data["pid"])/int(raw_port)acceptedfloat(silently truncating12.9to12), acceptedbool(Truebecame PID1), and raisedOverflowErroron values like1e999(parsed byjson.loadsasinf) — and the outer handler caught only(ValueError, OSError).read_pid_filenow uses a strict_coerce_pos_inthelper that rejectsbool,float, and non-digit strings, and only accepts integers in range; the outer handler also catchesOverflowErrorfor defense-in-depth. The documented contract that corrupt PID files returnNonenow holds. (filigree-626c12d368) -
Concurrent
create_observationcalls now return the existing live duplicate instead of raisingIntegrityError. The dedup path wasSELECT … if existing: return … else INSERTwith no writer lock underisolation_level="DEFERRED", so two callers could pass the pre-insert SELECT, one INSERT would succeed and the other would surfaceUNIQUE constraint failed: index 'idx_observations_dedup'through MCP as a generic database error — contradicting the documented contract.create_observationcannot wrap the whole function inBEGIN IMMEDIATEbecause callers invoke it withauto_commit=Falseinside their own transactions; instead, it now catchesIntegrityErroron the dedup index, rolls back, re-SELECTs the live duplicate, and returns it. OtherIntegrityErrorcauses (e.g. the still-presentregister_filerace) propagate unchanged. (filigree-248ca4ff3f) -
Concurrent
dismiss_observationandbatch_dismiss_observationsno longer write duplicate audit rows. Both paths read-then-deleted without serialization, so racing dismiss callers all wrote todismissed_observations(noobs_iduniqueness) and all reported success even though only oneDELETEactually removed a row. The SELECT/INSERT/DELETE sequence is now wrapped inBEGIN IMMEDIATE. Behavior change: a concurrent second dismiss now raisesValueError("Observation not found")instead of silently succeeding. (filigree-fd77d71a49) -
Priority validators now reject non-int (incl.
bool) before any write, andupdate_issuevalidates regardless of equality with current.create_issueandupdate_issuepreviously enforced only0 <= priority <= 4. Float priorities (2.5,3.5) passed the range check and were INSERTed beforeIssue.__post_init__rejected them at hydration, leaving a row withtypeof(priority)='real'durably committed while the caller saw aValueError. Bool priorities (True/False) slipped through both layers because Python'sboolis anintsubclass and0 <= True <= 4is0 <= 1 <= 4— silently coercingTrueto1. Additionally,update_issue's short-circuitpriority is not None and priority != current.priority and not (0 <= priority <= 4)skipped validation entirely when the new value equalled the current one by Python==; becauseTrue == 1,update_issue(priority=True)on a P1 issue was a silent no-op that looked successful. A new module-level_validate_priority_valueis now invoked fromcreate_issueand fromupdate_issuewheneverpriority is not None. (filigree-fa01508ee2) -
start_next_workno longer erases a pre-existing same-assignee claim on transition failure.claim_issueis intentionally idempotent for the same identity (existing same-assignee claims re-claim successfully without changing the row), andclaim_nextinherits that.start_next_workpreviously called_safe_release_claimunconditionally on any failure path — default-target template missing,canonical_working_status()raising, or theupdate_issuetransition raising — wiping out a claim thatalicehad owned before the call.start_workalready mirrors this scenario vianewly_acquired_claimownership tracking (filigree-31404d228f);start_next_worknow uses the same contract via a new private_claim_next_with_priorhelper that returns(Issue, prior_assignee)so the rollback closure can release only when this invocation acquired the claim. (filigree-fa01508ee2) -
SCHEMA_V1_SQLtest fixture restored to faithful v1 shape. The legacy v1-schema constant used by the migration test suite had drifted from what real v1 databases actually looked like: it was missing theissues.fieldscolumn and the seven coreissues/dependenciesindexes.migrate_v4_to_v5readsfields, so applying the migration chain to an unpatched fixture failed withMigrationError: Migration v4 → v5 failed: no such column: fields;tests/core/test_schema.pywas masking this with a manualALTER TABLE issues ADD COLUMN fieldsworkaround.migrate_v5_to_v6only adds the missing indexes inside its rebuild path, which is short-circuited when the parent self-FK is already correct (as it is in the fixture), so the indexes never reappeared. This is fixture-fidelity drift only — real v1 databases were created from the v1-eraSCHEMA_SQLand already had these columns and indexes — but the drift hid whether migrations would self-heal a degraded database.SCHEMA_V1_SQLnow matches the real v1 shape, the workaroundALTER TABLEis removed, and a newtest_migrated_v1_matches_fresh_schemaasserts column- and index-level equivalence between a v1→CURRENT migrated database and a freshSCHEMA_SQLto prevent recurrence. (filigree-c35e6fe9e9) -
undo_last()no longer restores a parent that creates a circular parent chain. Theparent_changedundo branch only checked that the previous parent still existed, bypassing the ancestor-walk cycle check thatupdate_issue()enforces on the normal write path. With an interleaved sequence —CreparentedA→B, thenAreparented underC— undoingC's last reparent would setC.parent_id = Aand form anA↔Ccycle that subsequently truncated subtrees inget_release_tree. The cycle check is now factored intoIssuesMixin._would_create_parent_cycle()and called from both write paths. (filigree-0a8c3d38d7) -
undo_last()serializes the candidate-selection read against concurrent callers. The SELECT-then-write candidate query usedNOT EXISTSto skip already-undone events, but theundonemarker insert happened later — so two concurrentundo_lastcalls on separate connections could both pass the check and both write markers for the same target event, silently consuming one entry from the user's undo history.undo_lastnow opens aBEGIN IMMEDIATEtransaction at entry to acquire SQLite's write lock before the SELECT, with afinallyclause that rolls back the lock on everyundone=Falseearly-return path. (filigree-f38d4e2874) -
fields_changedundo rejectsNULLand non-object stored values. The handler treated a missingold_valueas"{}"and only validated that the value was parseable JSON — so an imported or hand-edited event withold_value=NULL,"[]", or"text"could makeundo_lastreport success while clearingissues.fieldsto{}or writing a non-object JSON shape into the column, violating the model invariant. The handler now requiresold_valueto be present, parses it, assertsisinstance(parsed, dict), and persists a canonicaljson.dumps(parsed); otherwise it returnsundone=Falsewithout writing. (filigree-cb4cd68f80) -
check_scan_cooldown()blocks active runs regardless of age. The cooldown query applied a single 30-secondupdated_atcutoff topending,running, andcompletedrows. Becauseupdated_atis only refreshed on status transitions, a scanner that ran longer thanSCAN_COOLDOWN_SECONDSfell outside the cutoff and a duplicate trigger for the same(scanner, file_path)would pass the cooldown check and spawn a parallel process. The query now treats active rows (pending/running) as a singleton lock that always blocks, whilecompletedrows continue to use the timestamp cutoff as a debounce window. (filigree-254e1299a3) -
update_scan_run_status()uses compare-and-swap on the prior status. The method read the current status, validated the transition in Python, then issuedUPDATE scan_runs SET ... WHERE id = ?— with no guard against the row changing between read and write. Two concurrent writers (dead-PID auto-fail inget_scan_status, result-ingestion completion indb_files) could both observerunning, both pass validation, and the second commit would silently overwrite the first writer's terminal state. The UPDATE now includesAND status = ?(the previously-observed value) and raises a stale-transitionValueErrorwhenrowcount == 0, which the existing retry mitigation inprocess_scan_resultsalready handles. (filigree-c835e730fb) -
write_atomic()no longer collides on a shared temp filename. Concurrent writers to the same target both staged throughtarget.tmp: the second writer'sopen(...)truncated the first writer's in-flight stage,os.replace()could then install partial content or fail spuriously, and the failure-pathunlink()could delete the other writer's stage.write_atomicnow allocates a unique per-writer temp file viatempfile.mkstemp(dir=path.parent), matching the pattern already used bywrite_summary. The error cleanup test was also tightened to ignore unique temp suffixes. (filigree-9bb033331a) -
get_mode()raisesValueErroron non-stringmodevalues. A JSON-valid but non-hashablemode(e.g."mode": []) used to raiseTypeError: unhashable typefrom thefrozensetmembership test, escaping theValueErrorrecovery paths inhooks.py,cli_commands/admin.py, andinstall_support/doctor.py.get_modenowisinstance-checks the value and raises the same invalid-modeValueErrorfor any non-string. (filigree-cff0de463f) -
read_conf()rejects malformedprefix/db/enabled_packsvalues. A.filigree.confwith{"prefix":"x","db":[]}used to round-trip throughread_confand then crash downstream whenFiligreeDB.from_confevaluatedPath / data["db"]—TypeError: unsupported operand type(s) for /: 'PosixPath' and 'list'. Doctor's handled-failure path catchesValueErroronly, so theTypeErrorsurfaced as an unhandled crash.read_confnow type-checksprefixanddb(non-empty strings) andenabled_packs(list of strings, when present). (filigree-0f0e76f4b6) -
_seed_future_release()tolerates corruptfieldsJSON.FiligreeDB.initialize()queries release rows withjson_extract(fields, '$.version'); a single release row with malformedfieldsraisedOperationalError: malformed JSONand aborted the entire DB open, leaving the project unrecoverable without manual SQL surgery. The Future-singleton check is an idempotent maintenance step, not a place to enforce schema integrity, so it now guardsjson_extractwithjson_valid(fields)— matching the defensive handling already applied during migrations. (filigree-20ea5411e1) -
Category SQL predicates now compare
(type, status)pairs.list_issuesstatus-as-category filters and every blocker query (_build_issues_batchblocked_by / open-blocker counts /is_ready,has:blockersvirtual label,get_ready/get_blocked/get_critical_path,get_stats/_compute_virtual_has_counts,archive_closed) compared status names against a deduplicated category-state list. Once two enabled packs share a state name with different categories — the bundled templates already do this:incident.resolvediswip,debt_item.resolvedisdone— anincidentrow inresolvedmatched bothstatus="wip"andstatus="done"filters, and dependents of anincident.resolvedblocker hydrated withblocked_by=[]andis_ready=True. New_get_type_states_for_categoryand_category_predicate_sqlhelpers build type-aware predicates; the synthetic'archived'blocker semantic is preserved. (filigree-b55aa3191f) -
claim_issue()normalizes the assignee identity. Validation usedassignee.strip()but the original padded value was stored and used as the CAS guard, so claiming with" bob "blocked a later canonical"bob"claim with "already assigned to ' bob '".claim_issuenow runs the same_normalize_assigneestep thatcreate_issue/update_issuealready applied. (filigree-694f7e9bf8) -
start_work()rollback only releases claims it acquired.claim_issueis idempotent for the same identity, so callingstart_workagainst an issue you already own and hitting a transition failure used to wipe out the pre-existing claim (release_claimclears the assignee unconditionally). The rollback path now captures the prior assignee and skips the release when the row was already claimed by the same identity before the call. (filigree-31404d228f) -
update_issue()rejects empty / whitespace-only titles.create_issuerejected them;update_issuehad no equivalent guard, sotitle=""ortitle=" "silently overwrote a valid title. The same invariant now applies on update. (filigree-365dff403e) -
ProjectStore.get_db()no longer races on first open. In server mode, the lazy-open path was a check-then-open-then-assign with no lock, so two concurrent first requests for the same project key could both pass the cache-miss check, both runFiligreeDB.from_filigree_dir(which migrates and seeds), and silently leak the loser's handle — only the winner of the assign race was cached. Adds an internalthreading.Lockwith a fast-path cache hit, double-checked open, and lock-guardedreload()eviction andclose_all(). (filigree-732f6b31e4) -
Dashboard
/api/reloadresponse now matches the frontend contract. Backend returned{"status": "ok", **diff}; the shipped UI checksdata.okanddata.projects, so a successful reload rendered "Reload failed" and skipped the post-reload data refresh. The endpoint now returns{"ok": True, "status": "ok", "projects": N, **diff}— preserving every existing field for any direct API consumer. (filigree-173e76a28a) -
Dashboard
main()clears module-level_configon start and infinally._configis a persistent dict; previouslymain()reset_dband_project_storebut onlydict.update()-ed_config, so any keys absent from the next project's config (notablyname, whichread_configdoes not default) leaked from the previous run./api/projectsprefers_config["name"]over the live DB prefix, so a second in-process ethereal run could serve a stale project name. (filigree-154a23794c) -
filigree doctornow exits1when non-schema checks fail. Previously the command counted failures, printed them, and fell through with exit code0— silently green for CI scripts andset -eshells even whenconfig.jsonwas missing orcontext.mdwas stale. The schema-mismatch (v+1) exit code3still wins precedence;1is reserved for generic unfixed failures.--fixexits0only when every fixable failure was repaired. (filigree-467d1e7487) -
filigree initandfiligree doctor --fixnow honour the.filigree.confdbpath. Both paths previously constructedFiligreeDBdirectly against the legacy.filigree/filigree.db, bypassing the v2.0 anchor-aware constructors. On installs that relocate the DB via the conf,initre-runs and schema repairs silently created or migrated a phantom legacy DB while the project's actual DB stayed un-migrated. Both surfaces now useFiligreeDB.from_conf()when an anchor exists, falling back toFiligreeDB.from_filigree_dir()for legacy installs. (filigree-fa6309d551) -
filigree initon a legacy install now backfills.filigree.conf. The existing-project branch previously returned without writing the v2.0 anchor — only the fresh-init path created it — so re-runninginitto "upgrade" a legacy install left conf-anchored discovery (the strictfind_filigree_confwalk-up) unable to locate the project. Re-init now writes the anchor when missing and never overwrites an existing custom anchor. (filigree-f22fc98687) -
filigree install --mode servernow reloads a running daemon.installcalledregister_project()to write intoserver.jsonbut never POSTed/api/reload, so the daemon kept serving the stale registry until manually restarted. Now matches the behaviour offiligree server register(which already calls_reload_server_daemon_if_running()); reload failure is reported as a warning since registration is already committed. (filigree-80753e4b54) -
filigree dashboard --portandfiligree ensure-dashboard --portnow validate at the CLI boundary. Both flags were declared as plainintand accepted0, negative, and out-of-range values; in--server-modethe bogus value could be persisted into daemon state before the bind failed. They now useclick.IntRange(1, 65535), mirroringfiligree server start. (filigree-31da65493c) -
filigree.__version__no longer reports"0.0.0-dev"in source-only execution paths. Whenimportlib.metadata.versionraisesPackageNotFoundError(no installeddist-info),src/filigree/__init__.pynow falls through totomlliband reads[project].versionfrom the checkout'spyproject.toml, gated on[project].name == "filigree"so a parent project'spyproject.tomlcannot shadow ours."0.0.0-dev"is reserved as the final fallback when neither installed metadata nor a sourcepyproject.tomlis reachable. Affects thefiligree --versionoutput and the/api/healthversionfield for vendored or embedded deploys. (filigree-694e4821bd) -
filigree templates reloadno longer crashes with a rawValueErrorwhen.filigree/config.jsonis malformed. The CLI path now mirrors the MCP handler: corrupt config surfaces asErrorCode.VALIDATION(--json) or a clean stderr message (default), with exit code 1 in either case — no traceback. The reload also forcestemplates.list_types()to materialise the new registry and callscli_common.refresh_summaryso persistentcontext.mdreflects the change before the CLI process exits, closing the gap whereTemplates reloadedprinted success whilecontext.mdstayed stale.templates reloadnow accepts--json, emitting{"status": "ok"}on success. (filigree-259e5b58ef, filigree-00359c8498) -
Workflow CLI
--jsonerror paths now emit the 2.0 flat error envelope.type-info,transitions,validate, andexplain-statuspreviously printed plain stderr text on missing arguments even when--jsonwas set, leaking format-violating output into JSON-consuming pipelines. All four now route through a shared_emit_errorhelper that emits{error, code}withErrorCode.NOT_FOUNDon--jsonand falls back to plain stderr otherwise._guide_implalready followed this pattern; it now uses the same helper for consistency. (filigree-dfbcc84687) -
undo_lastnow reaches earlier reversible events when the newest reversible event has already been undone.db_events.py::undo_lastselected the latest reversible event and then returned "already undone" if it had a coveringundonemarker, leaving older reversible history unreachable. Repro: change title, change priority, undo (priority restored), undo again returned "already undone" instead of restoring the title. The candidate-selection query now filters out already-undone events viaNOT EXISTSso the fall-through to older history works. The post-select check is removed; the surfaced no-result reason changes from "Most recent reversible event already undone" to "No reversible events to undo". (filigree-a849860f2e) -
Archived blockers no longer re-block dependents in readiness, hydration, and
has:blockers.archive_closedwrites the literal status'archived'(preservingclosed_at), but no bundled workflow declares'archived'as a done state. The blocker-active queries indb_planning.get_ready,db_meta(get_stats,_compute_virtual_has_counts), anddb_issues(blocked_byhydration, open-blocker counts,has:blockersvirtual label) all checkedblocker.status NOT IN done_states, so archiving a closed blocker re-blocked its dependents — every dependent that became ready when the blocker closed flipped back to blocked once archival ran. A new_blocker_done_states()helper returns the workflow done states plus'archived'and is used at every blocker-check call site;_get_states_for_category("done")is preserved for archive selection (which must not include'archived') and label semantics. Mirrors the analytics-side fix shipped earlier in this section. (filigree-42045dd065) -
parent_changedevents are now undoable. The reparenting event was added to update/audit paths but_REVERSIBLE_EVENTSand theundo_lastmatchladder both omitted it, soundo_laston a reparented child returned "No reversible events" while the parent pointer remained set.parent_changedjoins the reversible set and a newcaserestoresparent_idfrom the event'sold_value— empty/None becomesNULL, a non-empty value is validated to still exist before being written back (we refuse rather than re-pointing at a deleted issue). (filigree-fc6bb28c23) -
import_jsonlnow normalizes ISO timestamps to canonical UTC._now_isoalways emits+00:00, but the import boundary indb_meta.import_jsonlpreservedcreated_at/updated_at/closed_atas supplied. SQLite TEXT compares lexicographically, so an imported2026-01-01T01:00:00+02:00(chronologically equal to2025-12-31T23:00:00+00:00) sorted after2026-01-01T00:00:00+00:00inget_events_sinceandarchive_closed'sclosed_at < ?cutoff — events were returned out of chronological order, and archival cutoffs miscompared. Newdb_base._normalize_iso_to_utcapplies on the import path to the columns these queries read:issues.created_at/updated_at/closed_atandevents.created_at. Other timestamp columns (dependencies.created_at,scan_runs.*_at,scan_findings.*_at,file_*.created_at,comments.created_at) are not yet normalized — extend the helper if a future query reads them lexicographically. Naive inputs are treated as UTC,Zsuffixes are accepted. Existing rows with non-UTC TEXT remain in the DB as-is — re-import to canonicalize if needed; no automatic data migration is performed. (filigree-20911dfe6d) -
Dependency undo handles
dep_typevalues containing':'.db_planning.add_dependencyandremove_dependencyrecord event payloads asf"{dep_type}:{depends_on_id}"; the corresponding undo cases indb_events.undo_lastparsed viasplit(":", 1), which assigned the wrong target whendep_typeitself contained':'(a namespaced dep type round-trips back as a malformed payload).rsplit(":", 1)puts the issue ID on the right side regardless; the legacy "no colon" branch is preserved for older event rows. No CLI/MCP/HTTP surface currently exposes a non-defaultdep_type, so the practical exposure is viaimport_jsonlof foreign-source events; the parse is now correct regardless of source. (filigree-2cd923c1d8) -
CLI
promote-findingnow honours the global--actorflag.cli_commands/files.py::promote_finding_cmddeclared a command-local--actordefaulting to"cli"and skipped@click.pass_context, sofiligree --actor bot-1 promote-finding <id>silently recorded"cli"in the audit trail — every other write-path command pulls the sanitized actor fromctx.obj["actor"]set incli.py. The command now defaults the local option toNoneand falls back to the group actor; an explicit local--actoris sanitized throughfiligree.validation.sanitize_actorso blank/control/overlong values are rejected at the CLI boundary instead of reaching the observation row. (filigree-cb82dc6b37) -
CLI
list-files,get-file-timeline,list-findingsclassifysqlite3.ErrorasIO, notVALIDATION. Three read commands incli_commands/files.pycollapsed(ValueError, sqlite3.Error)into a singleErrorCode.VALIDATIONenvelope. The unified envelope contract intypes/api.pylets callers branch onErrorCode, so misclassifying transient infra failures (locked database, table missing) as caller-input errors prevented retry logic from kicking in. The handlers now splitValueError(VALIDATION) andsqlite3.Error(IO), matching the siblingget-file/get-findingpattern. (filigree-ef5db29b89) -
CLI
get-issue-filesandadd-file-associationno longer leak raw SQLite tracebacks past the JSON envelope.get-issue-filescalleddb.get_issue_files()outside itssqlite3.Errorhandler, andadd-file-associationonly caughtKeyErroraround itsdb.get_file()/db.get_issue()existence checks. A locked or corrupt database at those lines bypassed--jsonenvelope handling and surfaced an uncaughtOperationalError. The lookups now share a singletryblock (orsqlite3.Errorhandler) so failures are reported as theErrorCode.IOenvelope consistently. (filigree-c7f94428c4) -
analytics.lead_timenow treats archived issues as completed.archive_closed()rewrites done-category status to the synthetic literal'archived'while preservingclosed_at. No bundled template declares'archived', so_resolve_status_categoryfell through to'open'andlead_time()returnedNonefor those rows.get_flow_metrics()still counted them inthroughput(via the dual-bucket scan) but silently dropped them from the lead-time average — the displayed mean was biased toward unarchived recent work.lead_time()now acceptsstatus='archived'with non-nullclosed_atas completed, restoring symmetry between the throughput and lead-time aggregates. (filigree-93777393d7) -
analytics.get_flow_metricsby_type[t]['count']now reports the closed-issue count, not the cycle-time-sample count.by_typewas built by appending only when_cycle_time_from_eventsreturned a value, so a closed issue without a WIP→done transition (allowed by the task workflow's directopen → closedpath intemplates_data.py) was silently absent fromby_typeeven though it counted toward overall throughput. The CLI labels the field as"X closed"(cli_commands/admin.py) and the dashboard renders it under "Count", so the displayed number under-reported real closed work for any type with direct-close issues.get_flow_metricsnow tracks per-type closed counts independently from cycle-time samples and emitsavg_cycle_time_hours=Nonewhen no samples exist. (filigree-744c36d621) -
CLI
changes --sincenormalizes non-UTC offsets to UTC.cli_commands/planning.py::_normalize_iso_timestampparsed the input but then returned the raw string, preserving its original offset. Stored events usedatetime.now(UTC).isoformat()(always+00:00) anddb_events.get_events_sincecompares them as SQLite TEXT, so a cursor like2026-06-15T13:00:00+01:00was lexically after a stored event2026-06-15T12:30:00+00:00even though chronologically the event was 30 minutes later — events were silently skipped. The normalizer now parses, attaches UTC if naive, and returnsparsed.astimezone(UTC).isoformat(), mirroring the dashboard/activityroute. (filigree-9aacfcd253) -
CLI
create-planrecursively validatesstepsanddeps.cli_commands/planning.py::create_planvalidated only the top-level shape, then handed unvalidated nested data todb.create_plan. A non-dict step ("steps": ["bad"]) leaked anAttributeErrortraceback becausestep_data.get(...)was called on a string, and a JSON float dep like0.1was silentlystr()-ed to"0.1"and reinterpreted as cross-phase refphase 0, step 1. The CLI now mirrors the MCP-layer rules (mcp_tools/planning.py::_validate_plan_deps): each step must be an object,stepsmust be a list, and each dep must beint >= 0or an"N"/"P.S"string — bools, floats, dicts, and malformed strings are rejected with a clean error. (filigree-c8eeb8f825) -
CLI
changes --limitrejects non-positive values. The option was a plainintand_changes_implpassed non-positive values straight through to SQLite, which treatsLIMIT -1as unbounded (and combined with the post-fetchraw[:limit]slice,--limit=-5returned all-but-the-last-5 rows). The option now usesclick.IntRange(min=1)on both thechangesandget-changescommands, matching the positive-limit contract used by the dashboard and MCP surfaces. (filigree-302ab21704) -
JSONL export/import now round-trips
scan_runs.db_meta.py::_EXPORT_TABLESlisted every persisted table exceptscan_runs, and the corresponding import bucket was missing too. A full export/import cycle silently dropped scan-run history, status, and cooldown anchors —get_scan_run("run-1")raisedKeyErrorafter the round trip. The export now emitsscan_runrecords and the importer rejects unknownstatusvalues up front (pending/running/completed/failed/timeout) before inserting all 15 columns. (filigree-6160591254) -
import_jsonl()enforces the same label-namespace invariants asadd_label(). The importer raw-inserted any label record, so a hand-crafted JSONL containingage:older_than_30d(a reserved virtual namespace) bypassed_validate_label_name()and shadowed the computedage:virtuals —list_labels()populates physical namespaces first, thensetdefault("age", …)becomes a no-op. The label import loop now validates each row, skips invalid ones with a logger warning, and counts them underskipped_types["<invalid_label>"]so the failure is visible. (filigree-22ed219abc) -
add_label("review:foo")reportsadded=Falseon no-op re-adds. The mutual-exclusivity DELETE for thereview:namespace turned an idempotent re-add into delete-then-reinsert, socursor.rowcountwas always 1 and the function returned(True, "review:foo")— propagating tocli_commands/meta.pyandmcp_tools/meta.pyas a false"added"status.add_label()now short-circuits when the existingreview:%set is exactly{normalized}, returning(False, normalized)without touching the table. Genuine replacements still return(True, …). (filigree-c9d223e24e) -
observeCLI accepts the documented--filealias.src/filigree/data/instructions.mddocumentedfiligree observe "note" --file=src/foo.py --line=42, butcli_commands/observations.pyonly registered--file-path, so the documented form failed withError: No such option: --file. The option now declares both--file-pathand--fileagainst the samefile_pathparameter. (filigree-6f8d9816b7) -
Observation CLI commands surface
sqlite3.ErrorasErrorCode.IO.observe,list-observations,dismiss-observation, andpromote-observationonly caughtValueError(or had no exception handler at all), so a transient SQLite failure (e.g.OperationalError("database is locked")) escaped uncaught and--jsoncallers got an empty stdout plus an unhandled traceback instead of the{"error": ..., "code": "IO"}envelope already used bymcp_tools/observations.py,cli_commands/files.py, and the neighboringbatch-dismiss-observationscommand. Each affected handler now wraps its DB call inexcept sqlite3.Errorand emits{"error": f"Database error: {e}", "code": ErrorCode.IO}. (filigree-9ca1f5ace8) -
/api/loom/changescanonicalizessinceto UTC before SQLite text-compare.dashboard_routes/analytics.py::api_loom_changesonly rewrote a trailingZto+00:00and passed the raw offset string todb.get_events_since(), which compares against stored+00:00ISO timestamps lexically. Twosincevalues representing the same instant but with different offsets returned different events. The handler now parsessince, treats naive timestamps as UTC, and passesparsed.astimezone(UTC).isoformat()to the DB — mirroring the classic/api/activityroute. (filigree-d808d8b70f) -
/api/loom/changesrejects?offset=query param. The loom contract (tests/fixtures/contracts/loom/changes.json) declares the cursor is thesincetimestamp;offsetis not exposed. The handler reused the generic offset-pagination helper, validatingoffsetbut then discarding it. Any caller passing?offset=got an undocumented silent no-op.api_loom_changesnow parses onlylimitand rejectsoffsetwith a 400VALIDATION. (filigree-f0f47f5b9d) -
Graph v2
?types=validates against registered template types.dashboard_routes/analytics.py::_parse_graph_v2_paramsvalidated the filter against{i.type for i in issues}, so a registered type with no current issues (e.g.releasein a fresh project, orbugafter all bugs are closed) was rejected as "Unknown". Validation now usesdb.templates.list_types()— the canonical source already used for/api/typesand issue creation. (filigree-68c24cee62) -
Scanner CLI surfaces
ForeignDatabaseErrorinstead of a generic "not initialized" line.cli_commands/scanners.py::_get_filigree_dircaught(ProjectNotInitialisedError, Exception)and returnedNone, which every caller then converted into"Project directory not initialized"withErrorCode.NOT_INITIALIZED. BecauseForeignDatabaseErroris aProjectNotInitialisedErrorsubclass, the rich "Refusing to latch onto another project's filigree database…" diagnostic — explicitly contracted bytest_doctor.py::test_foreign_database_is_reported_with_specific_message— was silently dropped forlist-scanners,trigger-scan,trigger-scan-batch, andpreview-scan. The helper now raises and a new_resolve_filigree_dir_or_dieemitsstr(exc)so the rich message reaches the user. (filigree-ae5a8db639) -
report-findingvalidates field types and mapsValueErrortoVALIDATION.cli_commands/scanners.py::report_finding_cmdonly checked truthiness, so a JSON"severity": []raisedTypeErrorfromseverity not in VALID_SEVERITIES(unhashable membership), and non-string-but-truthypath/rule_id/message/line_start/line_endslipped past to the DB validator — whoseValueErrorwas then mismapped toErrorCode.IO(the HTTP route atdashboard_routes/files.pyalready mapped the same exception toVALIDATION). The CLI now isinstance-checks every field, splits theValueErrorandsqlite3.Errorbranches, and emitsVALIDATIONfor caller-side malformed input. (filigree-a59f82c87b) -
_read_graph_runtime_configreads.filigree/config.jsonfrom the project root, not the DB's parent.dashboard_routes/common.py::_read_graph_runtime_configderived the config directory fromdb.db_path.parent, which is the.filigree/metadata dir only for the legacy.filigree/filigree.dblayout. For.filigree.confprojects with a relocated DB (e.g.storage/track.db),read_configsilently looked instorage/and fell back to defaults — sograph_v2_enabled/graph_api_modefrom.filigree/config.jsonwere ignored on/api/configand/api/graph. Now derives the config directory fromdb.project_root / FILIGREE_DIR_NAMEwhen present, falling back todb.db_path.parentfor direct construction. (filigree-a9bedb09a9) -
/api/files/hotspotsand/api/scan-runscaplimitat_MAX_PAGINATION_LIMIT. Both endpoints parsedlimitvia_safe_intwith onlymin_value=1— the same bypass-of-_parse_paginationpattern fixed in filigree-393cfab62c for/api/files. A request like?limit=9223372036854775808passed route validation and then crashed sqlite3 binding withOverflowError: Python int too large to convert to SQLite INTEGER, surfacing as 500. The two limit-only routes now passmax_value=_MAX_PAGINATION_LIMITso oversize values return a 400VALIDATIONresponse before reaching the database layer. (filigree-873962aa58) -
promote_observationnow serializes the idempotency check + create_issue.db_observations.py:promote_observationcheckedjson_extract(fields, '$.source_observation_id')with a plain read on an autocommit transaction, then calledcreate_issue— two concurrentFiligreeDBconnections could both pass the check and both insert, leaving two issues for one observation (no UNIQUE constraint backstop). The check + insert is now wrapped inBEGIN IMMEDIATEso peers queue on the writer lock and see the committed row when they retry the check (mirrorsdb_scans.py:trigger_scan_locked). (filigree-58aa8fb4ac) -
promote_observationtolerates corruptissues.fieldsJSON on unrelated rows. The idempotency lookup ranjson_extractover the entireissuestable, so one malformedfieldsrow anywhere — the kind_safe_fields_jsonexists to absorb at the read path (db_issues.py:99) — raisedOperationalError: malformed JSONand broke every promote. The query now guards withjson_valid(fields), restoring the project-wide convention of surviving corrupt JSON. (filigree-9bb842088d) -
Pagination params can no longer overflow SQLite
LIMIT/OFFSETbinds.dashboard_routes/common.py::_parse_paginationonly enforced minimums, so?limit=9223372036854775807(or anyoffsetpast int64 max) propagated unchecked intoLIMIT ? OFFSET ?— and even at int64 max the routes that overfetch bylimit + 1(dashboard_routes/issues.py:693) overflowed on bind, raising an uncaughtOverflowErrorfromsqlite3and surfacing as 500. Added module caps_MAX_PAGINATION_LIMIT = 10_000and_MAX_PAGINATION_OFFSET = 2**63 - 2; extended_safe_intwith an optionalmax_value. Pagination violations now return 400VALIDATION. (filigree-393cfab62c) -
_semver_sort_keyfalls back to title whenversionis non-empty but unparseable.dashboard_routes/releases.pypreviously gated title-based Future detection onnot versionand builttext = version or titlefor loose-semver fallback, so any non-empty junk string in the importedversionfield blocked both paths. Releases with{"version": "planned", "title": "v2.0.0"}or{"version": "junk", "title": "Future"}now sort correctly. Priority is reordered: version Future → version semver (strict, then loose) → title Future → title loose semver → non-semver. Whitespace-onlyversionis treated as absent. (filigree-5e1e2e0eae) -
filigree get-comments --jsonwraps items in the ListResponse envelope.get-commentswas missed by the Phase E1--jsonmigration and emitted a bare JSON list instead of{items, has_more}. The CLI now matches MCP's_list_response(...)shape and the loom HTTPGET /api/loom/issues/{id}/commentscontract. Per-item shape (classicCommentRecordwithid) is preserved to maintain CLI↔MCP parity. (filigree-d2263e721d) -
filigree batch-close --jsonomitsnewly_unblockedwhen empty. The CLI emitted"newly_unblocked": []unconditionally, contradictingBatchResponse'sNotRequiredrule (types/api.py:392-398) and the loomPOST /api/loom/batch/closefixture, both of which require the field to be omitted entirely when no issue was unblocked. The CLI now mirrorsmcp_tools/issues.py::_handle_batch_close: present only when at least one issue became ready as a result of the close. (filigree-893edb553a) -
filigree server start --portvalidates the TCP range at the CLI boundary. The Click option used baretype=int, so--port 0silently fell throughport or config.port(server.py:248) and out-of-range values (negative or >65535) bypassedServerConfig.__post_init__because the daemon path assigns toconfig.portafter construction.--portnow usesclick.IntRange(1, 65535)and rejects invalid input with a clear usage error before reaching the daemon launch path. (filigree-1e1cb5eeeb) -
CLI startup failures honour
--jsonenvelope.cli_common.get_db()now emits the 2.0 flat{error, code}envelope on stdout when the active invocation passed--jsonand project discovery or DB-open fails — instead of leaking a plain-text stderr message that every JSON-capable command (e.g.stats --json) inherited at startup. Mapping:ProjectNotInitialisedError→NOT_INITIALIZED,SchemaVersionMismatchError→SCHEMA_MISMATCH,OSError/sqlite3.Error→IO,ValueError/TypeError/KeyError→VALIDATION. Plain-text output (without--json) is unchanged. (filigree-3741fc571b) -
_safe_json_loads: out-of-band corruption flag, no in-band sentinel keys.Issue/FileRecord/ScanFindingno longer falsely strip user data named_fields_erroror_metadata_error. The helper now returns a_ParsedJson(dictsubclass) carrying a_filigree_corruptattribute;models.py::to_dict()consults the attribute via duck-typing rather than mining a user-visible dict key. Custom fields/metadata with those legacy names round-trip unchanged. (filigree-7ea6b80f3b §1) -
_safe_json_loadshandles undecodable bytes from BLOB-typed columns. SQLite's flexible typing can returnbytesfor JSON-text columns when the row contains BLOB data; invalid-UTF-8 input previously raisedUnicodeDecodeErrorpast the safety net. The helper now acceptsstr | bytes | Noneand treatsUnicodeDecodeErroras corruption, matching the documented contract. (filigree-7ea6b80f3b §2) -
--jsondetection ignores tokens after Click's--terminator. The raw-argv scan that drives JSON-mode envelope emission previously matched any literal--jsontoken, including positional values that follow--(e.g.filigree create -- --jsonmakes the title"--json"). Group-level--actorvalidation andget_db()startup failures now share one helper that slices at the first--before the membership test, so non-JSON invocations get plain Click usage / stderr output. (filigree-df988a37fc)
2.0.0 — 2026-04-28 — The Filigree Component of Loom
Filigree 2.0 reframes the product from "standalone issue tracker with an HTTP API"
to "standalone issue tracker, plus a loosely-coupled component of the Loom federation."
This release adds a stable HTTP generation contract (/api/loom/*), forward-migrates
MCP and CLI to the loom vocabulary, ships composed operations (start_work /
start_next_work), brings CLI to full parity with MCP, and adds schema-mismatch UX
across every entry point.
- MCP forward-migrated to the loom vocabulary (Phase D of the 2.0 federation work package).
- Single-issue tool input field renamed.
id→issue_idacrossget_issue/update_issue/close_issue/reopen_issue/claim_issue/release_claim/undo_last/add_comment/get_comments/add_label/remove_label/get_issue_events.list_issues.parent_idfilter renamed toparent_issue_id. - Batch tool input field renamed.
ids→issue_ids(batch_update/batch_close/batch_add_label/batch_add_comment);ids→observation_ids(batch_dismiss_observations).batch_update_findingsalready usedfinding_ids— no change. - Dependency tools renamed.
add_dependency/remove_dependencytakefrom_issue_id/to_issue_id(wasfrom_id/to_id). - Observation tools renamed.
dismiss_observation/promote_observationtakeobservation_id(wasid). - Create-issue parent rename.
create_issue.parent_id/update_issue.parent_idinput fields renamed toparent_issue_id. - SlimIssue projection.
SlimIssue.idrenamed toSlimIssue.issue_id. Affects every MCP tool emitting slim projections (search_issues,get_ready,get_blocked, batch responsesucceeded[]/newly_unblocked[],close_issueresponsenewly_unblocked[]). - Batch container keys unified. Legacy
{updated|closed, errors, count}andBatchActionResponse.resultsconsolidated to{succeeded, failed}perBatchResponse[T].batch_close/batch_updatereturnBatchResponse[SlimIssue](succeeded carries slim projections);batch_add_label/batch_add_comment/batch_dismiss_observations/batch_update_findingsreturnBatchResponse[str]. - List response envelope unified. Every MCP list tool
(
list_issues/search_issues/get_ready/get_blocked/get_comments/get_issue_events/get_changes/list_files/list_findings/list_observations/list_scanners/list_packs/list_types/list_labels) returnsListResponse[T] = {items, has_more, next_offset?}. Drops legacy siblings (total,stats,errors,hint,limit,offset);list_labelsflattens its dict-of-namespaces to a list of{namespace, type, writable, labels}entries. - Workflow tools renamed.
get_workflow_states→get_workflow_statuses(response keystates→statuses);explain_state→explain_status(input argstate→status, response keystate→status). CLI commands follow:workflow-states→workflow-statuses,explain-state→explain-status. get_issue.include_filesdefaults toFalse. Aligns with the loom HTTPGET /api/loom/issues/{issue_id}contract; consumers needing the file-association payload passinclude_files=true.
- Single-issue tool input field renamed.
- MCP composed operations:
start_workandstart_next_work. Atomic claim+transition tools backed byFiligreeDB.start_work/FiligreeDB.start_next_work.target_statusdefaults to the type'scanonical_working_status(); ambiguous (multi-wip) types raiseAmbiguousTransitionErrorso the caller specifies. Rollback uses compensating actions — if the transition fails after a successful claim,release_claimis called to restore the prior assignee, and the audit trail preserves bothclaimedandreleasedevents. TypeTemplate.canonical_working_status()helper. Returns the unique wip-category status name; raisesAmbiguousTransitionErroron multi-wip types andInvalidTransitionErroron no-wip types.
-
Classic HTTP unchanged. Federation consumers using
/api/loom/*are unaffected (the loom shape has been the contract since Phase C). -
MCP clients pinning to legacy schemas (
id/ids/{updated, errors},statearg,WorkflowStatesResponse,StateExplanation,IssueListResponse,SearchResponse,BatchUpdateResponse,BatchCloseResponse,BatchActionResponse) must update to the new shapes. The legacy TypedDicts have been removed fromfiligree.types.api. -
Cross-surface error-envelope parity test module (
tests/util/test_cross_surface_parity.py). Sixteen tests fire the same logical bad input at dashboard (AsyncClient), MCP (in-process tool handler), and CLI (CliRunner --json) and assert the three surfaces emit the sameErrorCode. Covers the seven bed-down cases from Stages 1 + 2a (NOT_FOUNDon get,VALIDATIONon out-of-range priority / unknown type / blank actor / blank assignee,INVALID_TRANSITIONon bad status,CONFLICT/INVALID_TRANSITIONon already-closed, batch-per-item envelopes) plus fourPOST /api/v1/scan-resultsenvelope pins — the dashboard-only route is the highest-risk Clarion-facing hop for Stage 2B and has no staging environment, so these tests are the pre-release contract. Twelve tests pass; four are strictxfailmarking 2B worklist items (CLI--priority/--actorClick-layer validators bypassing the 2.0 envelope; CLIclose --jsonemitting a batch-shape wrapper for single-id close; dashboardbatch_updatereturningerrorswhile MCP returnsfailed— wire-contract unification scope for 2B). Each divergence is also filed as anobservefor the 2B rebaseline's work list. -
Stage 2B task 2b.3c — CLI
filigree close <id> --jsonemits the flat 2.0 envelope on single-id close failure. Previously the CLI always returned the batch-shape wrapper ({closed, unblocked, errors:[{id,error,code}]}) even for a single-id close. Whenlen(issue_ids) == 1and the close failed, the command now emits the flat envelope ({"error": ..., "code": ...}) matching the dashboard and MCP shapes. Multi-id close calls (filigree close a b c --json) keep the batch-shape wrapper since batching is the documented behaviour for N≥2. ConvertsTestAlreadyClosedParity::test_cli_emits_flat_envelopefrom strict-xfail to passing. -
Stage 2B task 2b.3b — CLI
--actor(group-level) now emits the 2.0 envelope on--json.filigree --actor " " update <id> --title x --jsonpreviously emitted Click's stderr usage error (Error: Invalid value for '--actor': actor must not be empty) with exit 2; callers using--jsongot no JSON output. The group now uses a_FiligreeGroupsubclass whoseparse_argsstashes the raw invocation intoctx.meta["filigree_raw_args"]; the group callback checks that stash for--json(sincectx.args/ctx.protected_argsare empty at group-callback time, andsys.argvis untouched byCliRunner), and emits the envelope when found. Non-JSON invocations keep the existingclick.BadParameter→ stderr behaviour. ConvertsTestBlankActorUpdateParity::test_cli_emits_envelopefrom strict-xfail to passing. -
Stage 2B task 2b.3a — CLI
--prioritynow emits the 2.0 envelope on--json.filigree create --priority 99 --jsonpreviously emitted Click's stderr usage error with exit 2, becauseclick.IntRange(0, 4)intercepted the value at parse time and bypassed any--json-aware output. The range check moved into the command body whereas_jsonis reliably available (callbacks fire in cmdline order —--priority 99 --jsonprocesses priority first, soas_jsonmay not yet be inctx.paramsat callback time). ConvertsTestPriorityOutOfRangeCreateParity::test_cli_emits_envelopein the parity module from strict-xfail to passing. CLI now matches dashboard and MCP on out-of-range priority:{"error": ..., "code": "VALIDATION"}with exit 1. -
Stage 2B task 2b.4 —
classify_value_errorboundary rule (docs + enforcement test). The substring-based ValueError classifier (src/filigree/types/api.py) now documents its boundary rule explicitly in its docstring: state-machine sites MUST use the helper (enumerated:db._batch_with_transition_errors, MCP update/close/reopen handlers, CLI update/close/reopen--jsonpaths, dashboard PATCH/close/reopen routes); input-validation sites MUST hardcodeVALIDATION(enumerated:dashboard_routes/common.py,mcp_tools/meta.py/files.py/scanners.py/observations.py).tests/util/test_classify_value_error_boundary.pygreps the input-validation modules forclassify_value_errorimports and fails CI if the rule is broken in a future change. Prevents the foreseeable drift where someone reaches for the helper at a site where it would mis-classify future error-message additions. -
Stage 2B task 2b.-1 —
POST /api/v1/scan-resultssuccess-shape pin.TestScanResultsEnvelope::test_success_shape_empty_findingsasserts the exactScanIngestResultkey set (eight keys) and value-type invariants returned on 200 for a valid empty-findings body. Closes the gap identified in the 2B rebaseline §4: the four pre-existing error-path tests pinned 400+VALIDATIONshapes, but the 200 shape was unpinned. Any 2B task that changesdb.process_scan_results(...)'s return dict must update this test in the same commit; absent a Clarion staging environment, this is the concrete pre-release contract.
- CLI forward-migrated to the loom vocabulary (Phase E of the 2.0 federation work package).
add-labelarg order reversed.filigree add-label <label> <issue_id>(was:<issue_id> <label>). Aligns with the already-correctbatch-add-label <label> <issue_ids...>order. Scripts using the old positional order must update. This is the only positional-arg break in Phase E.--jsonlist envelopes unified. All CLI list commands now emit{items, has_more, next_offset?}(ListResponse[T]) on--json. Previously some emitted bare lists or legacy shapes ({issues: [...]},{observations: [...]}). Clients pinning to legacy--jsonlist output must re-pin.- Slim-issue
--jsonprojection usesissue_id. Slim projections inready --json,blocked --json,search --json,batch-update --json,batch-close --jsonnow useissue_id(wasid), matching the loom/MCP shape.
-
Phase E CLI commands — observations, files, scanners (Phase E2). Every MCP tool now has a CLI counterpart. New modules:
cli_commands/observations.py—observe,list-observations,dismiss-observation,promote-observation,batch-dismiss-observations.cli_commands/files.py—list-files,get-file,get-file-timeline,get-issue-files,add-file-association,register-file,list-findings,get-finding,update-finding,promote-finding,dismiss-finding,batch-update-findings.cli_commands/scanners.py—trigger-scan,trigger-scan-batch,get-scan-status,preview-scan,report-finding,list-scanners. All commands emit loom-shape JSON on--json.
-
Verb-noun CLI aliases (Phase E3, permanent). Every existing short-form CLI command gains a permanent verb-noun alias matching the MCP tool name:
get-ready,get-blocked,get-plan,get-changes,get-critical-path,get-valid-transitions,validate-issue,get-workflow-guide,get-type-info,list-types,list-packs,list-labels,get-label-taxonomy,update-issue,get-issue,list-issues,release-claim,get-issue-events,undo-last. Both names appear in--help; both are stable. -
filigree start-workandfiligree start-next-work(Phase E4). CLI wrappers for the D6 composed operations. Backed byFiligreeDB.start_work/start_next_work(same path MCP uses). Returns the updated issue dict on success;ErrorResponseon failure. -
filigree show --with-filesflag (Phase E5).filigree show <id>no longer includes file associations by default — matchesget_issue.include_files=False(D4) and loom HTTPGET /api/loom/issues/{issue_id}(since C3). Pass--with-filesto opt in. -
CLI↔MCP↔HTTP parity battery (Phase E7).
tests/util/ test_cross_surface_parity.pyextended with five new envelope- equivalence tests covering the new CLI commands:list-observationsCLI↔MCP,list-filesCLI↔loom-HTTP,start-workerror and success shapes CLI↔MCP. The Phase D gate ("MCP↔HTTP parity") is now "CLI↔MCP↔HTTP parity".
- Classic HTTP unchanged. The C2
test_container_key_paritystrict xfail remains strict-xfailed; Phase E did not touch classic. - CLI clients pinning to legacy
--jsonshapes (bare lists,{issues: [...]},{id: ...}in slim projections,<issue_id> <label>positional order onadd-label) must update. The new loom-shape envelopes are the stable forms going forward.
- Stage 2B task 2b.0 —
BatchFailureDetailretired in favour ofBatchFailure(Python API only; no wire-contract change). The unused Stage 1BatchFailure.item_idfield reverted toidbefore the type got any live wire consumers, sodb_issues.pybatch constructions, the three legacy response types (BatchUpdateResponse,BatchCloseResponse,BatchActionResponse), and thevalid_transitionsenrichment atdb_issues.py:899all migrate cleanly. The HTTP/MCP/CLI surfaces emit the same{id, error, code, valid_transitions?}shape for batch failures.BatchFailuregains the optionalvalid_transitions: NotRequired[list[TransitionHint]]field thatBatchFailureDetailpreviously carried. Python consumers importingBatchFailureDetailfromfiligree.types.apinow raiseImportError; migrate toBatchFailure.
-
2.0 envelope bed-down — residual cross-surface parity fixes.
db._batch_with_transition_errors(used bybatch_closeandbatch_update) now routes per-itemValueErrors throughclassify_value_errorinstead of hardcodingErrorCode.INVALID_TRANSITION. Validation-class errors ("Field validation failed: …","Priority must be between 0 and 4","Cannot close issue {id}: hard-enforcement gate requires fields: …") now surface asVALIDATIONacross dashboard batch endpoints, MCPbatch_*tools, and CLI--jsonoutput.valid_transitionsenrichment is gated oncode == INVALID_TRANSITION, so validation failures no longer get a meaningless transition list attached.- CLI
claimandclaim-nextnow pre-validate the--assigneevalue, matching the MCP handlers (mcp_tools/issues.py:603-604,646-647). A blank or whitespace assignee now returnsErrorCode.VALIDATIONat both surfaces; previously the CLI fell through todb.claim_*, caught the "Assignee cannot be empty"ValueError, and miscoded it asCONFLICT. TheValueErrorhandler inclaim-nextnow emitsVALIDATION(the only remaining propagated case) to match the MCP handler's code.
-
filigree-1c7b2776a5 (P1):
release_claim()is now atomic — it no longer silently erases a newer claim. Previously the method read the current assignee viaget_issue(), then issued an unconditionalUPDATE ... WHERE id = ?. A concurrentclaim_issue()orupdate_issue(assignee=...)landing in the gap between those two statements had its write overwritten by the unguarded clear, with no error surfaced to either caller. The siblingclaim_issue()has used compare-and-swap for exactly this reason.release_claim()now matches:SELECT assigneeonce, thenUPDATE ... WHERE id = ? AND assignee = ?with the observed value, and branches onrowcount == 0to distinguish deleted (KeyError: not found), already-released (ValueError: already released), and reassigned (ValueError: reassigned to 'X') — the latter two land in the existing 409CLAIM_CONFLICTpath at every boundary (dashboard HTTP, MCP, CLI) with no caller changes. -
Core subsystem cluster (1 P2 + 1 P3 bug):
- filigree-3449322141:
FiligreeDB.from_filigree_dirandfrom_confno longer leak the SQLite connection wheninitialize()raises. The classmethods now wrapdb.initialize()in try/except, calldb.close()on failure, and re-raise.initialize()'s first statement opens the connection lazily viaget_schema_version()→self.conn, so a downstream failure (schema newer than this build supports, migration error, seed failure) previously exited the factory beforereturn db, leaving the caller without a handle to close. The leaked connection — plus its WAL/SHM sidecar files — survived until the interpreter exited or GC collected the instance, which in long-lived processes like the dashboard never happened. - filigree-29bc9117ab:
filigree/__init__.pyno longer eagerly re-exportsFiligreeDBandIssue. Both are still accessible on the package (filigree.FiligreeDB,filigree.Issue) but resolve via a PEP 562 module-level__getattr__. Previously, any import offiligreeor of a lightweight submodule (e.g.from filigree import migrations) paid for loading the entire DB mixin stack (db_issues,db_files,db_scans,db_workflow, etc.) and the templates loader. Import-time cost drops to the package's own trivial work for callers that do not touch the DB API.
- filigree-3449322141:
-
Analytics-correctness cluster (1 P1 + 1 P3 bug):
- filigree-fc30d6efd9:
/api/graphno longer silently truncates large projects at a hidden 10000-row preload. The handler now paginateslist_issuesthrough every row before buildingissue_mapand running v2 filters, so projects beyond the old cap stop losing nodes andscope_rootvalidation stops false-404ing for any issue past the first 10000. The page size (_GRAPH_LIST_PAGE_SIZE = 1000) is a module-level constant so tests can exercise pagination boundaries. - filigree-0fe4558ea9:
get_flow_metricsno longer double-counts issues returned by both thestatus="closed"(template-defined done states) andstatus="archived"(literal) scans. The two buckets overlap when a workflow pack defines anarchiveddone state, or whenarchive_closed()runs mid-scan. Results are now deduped byissue.idbefore throughput count and cycle-/lead-time averaging, so a single archived issue contributes exactly one observation to the window.
- filigree-fc30d6efd9:
-
Silent failure cluster (2 P2 + 2 P3 bugs):
- filigree-769a192252:
_safe_json_loadsnow returns{error_key: True}when parsed JSON is valid but not a dict (arrays, scalars), matching the behaviour for malformed JSON. Previously the non-dict branch returned bare{}, so callers (Issue.fields,FileRecord.metadata,ScanFinding.metadata) saw an empty payload with no warning, andto_dict()emitted zerodata_warnings. A corrupt column containing"[1,2,3]"now produces the same corruption signal as"{bad json". - filigree-c6c7842661: MCP
get_issueno longer swallowssqlite3.Errorfrom the file-association lookup and masquerade-returnsfiles: []. The handler now fails fast — matchingdashboard_routes/issues.pyand the dedicatedget_issue_filesMCP tool — so schema/query failures surface as MCP errors instead of being misread as "no associations". - filigree-613e9f5f66:
filigree update --design ""now clears thedesignfield as documented. The previous truthiness guard (if field or design:) treated an empty-string value as "not provided" and dropped the update entirely; the check now distinguishes unset (None) from cleared (""). - filigree-55c5347992:
_build_transition_errorno longer lets a backend failure during transition enrichment (get_valid_transitionsre-reads the issue from SQLite) escape and mask the caller'sinvalid_transitionpayload. The enrichment branch now catches any exception with a debug log and returns the original error intact, so a transient "database is locked" during error construction does not turn a handled validation failure into a crash.
- filigree-769a192252:
-
Logging observability cluster (1 P2 + 1 P3 bug):
- filigree-0983c839c5:
_JsonFormatternow emits the full traceback and exception class forexc_info=Truelogs. Previously the formatter reimplementedlogging.Formatter.format()and serialised exceptions as onlystr(record.exc_info[1]), so aKeyError("missing-key")logged as"exception": "'missing-key'"— no class, no traceback. Across the 50+exc_info=Truecall sites in hooks, MCP, and DB layers, every production error log was silently losing the diagnostic detail that madeexc_infoworth asking for. The formatter now addsexception_type(class name) andtraceback(formatted stack) fields alongside the existingexceptionmessage, and includes astackfield when callers passstack_info=True. - filigree-c9ee8025cc:
setup_loggingnow closes every stale or duplicateRotatingFileHandlerinstead of bailing out after the first path match. The old loop scannedlogger.handlersand returned as soon as it found one matching handler, so a handler list shaped like[matching, stale]or[matching, duplicate]— produced by a prior leak, a failed reconfiguration, or external code adding a handler — left the trailing entries attached forever, keeping file descriptors open and writing records to the wrong file. The new loop walks the full list, keeps at most one matching handler, and closes/removes every other rotating handler before returning.
- filigree-0983c839c5:
-
Install integrity cluster (2 P2 bugs):
- filigree-c0312c2f6c:
inject_instructionsnow fully repairs a filigree block whose end marker is missing. Previously the repair preserved everything after the opening marker, so the stale body became orphan content below the newly-inserted block; subsequent runs found the new end marker and treated that orphan tail as legitimate user content, so the corruption lived forever. An unclosed block is now replaced from the opening marker through EOF — content before the marker is preserved, content after is assumed to belong to the broken block. Regression test runs the repair twice and asserts convergence. - filigree-b74c12e014:
ensure_gitignoreno longer accepts any.filigree/substring as proof that the directory is ignored. The check now parses.gitignoreline by line, skipping blank/comment/negated lines and matching only normalised forms (.filigree,.filigree/,/.filigree,/.filigree/). Previously#.filigree/(a comment),!.filigree/(a negation that un-ignores), andsrc/some/.filigree/cache/(a subpath) all short-circuited the check, so projects could end up reporting "already ignored" while the real.filigree/directory remained tracked.
- filigree-c0312c2f6c:
-
Install / doctor cluster (5 P2 bugs):
- filigree-e1ef3675f7:
filigree install --claude-codeand--codexnow install only the MCP, matching their help text. Previously both flags implicitly pulled in hooks + skills (and--codexpulled in codex-skills), making it impossible to update an MCP config alone and duplicating behaviour that the dedicated--hooks,--skills, and--codex-skillsflags already cover. - filigree-e671d07d56:
filigree server registerandfiligree server unregisterno longer exit non-zero when the daemon reload step fails. The registry change is already committed by that point, so a reload failure is a best-effort warning ("restart the daemon manually") rather than a masked-success error. Scripts chaining these commands no longer misinterpret a completed registration as a failure. - filigree-36539914b3:
filigree doctornow detects broken module-formSessionStarthooks. For hooks shaped likepython -m filigree session-context, the interpreter existing is necessary but not sufficient — doctor additionally runspython -c "import filigree"to verify the module is still installed in that interpreter, catching venv purges and pip uninstalls that previously left the hook looking healthy. - filigree-9fb21f2b4b:
install_claude_code_hooksno longer appends newSessionStarthooks to a user block that merely mentions "filigree" in a command. Reuse is now strict: only a block whosematcheris empty/missing AND that already holds a recognised filigree hook command (via_hook_cmd_matches) is a valid reuse target. Otherwise a dedicated unscoped block is created, sosession-contextandensure-dashboardfire for every session source (startup, resume, clear, compact) instead of inheriting a narrower user matcher. - filigree-09d0dff729:
_find_filigree_mcp_commandnow probes bothfiligree-mcpandfiligree-mcp.exein the uv-tool branch. Previously the Windows filename was skipped in favour of the bare-filigree-mcpfallback, even when an absolute~/.local/bin/filigree-mcp.exeexisted.
- filigree-e1ef3675f7:
- Unified error envelope (2.0 wire shape). Every error response across MCP tools, dashboard routes, and CLI
--jsonoutput — including the per-itemfailed[]entries inside batch responses — now emits the same flat shape:{"error": "<message>", "code": "<UPPERCASE_CODE>", "details"?: {…}}. The 11-memberErrorCodeenum (VALIDATION,NOT_FOUND,CONFLICT,INVALID_TRANSITION,PERMISSION,NOT_INITIALIZED,IO,INVALID_API_URL,STOP_FAILED,SCHEMA_MISMATCH,INTERNAL) replaces 27 ad-hoc lowercase codes that previously differed per surface.ErrorResponseis defined as aTypedDictand the dashboard helper_error_responsenow constructs through it so mypy gates the shape at every emit site; a newerrorcode_to_http_status()function usesmatch+assert_neverso adding a 12th member fails the build. classify_value_error(message)helper infiligree.types.api— substring heuristic that mapsValueErrormessages mentioningstatus/transition/statetoErrorCode.INVALID_TRANSITIONand everything else toErrorCode.VALIDATION. Previously the MCP surface used the heuristic inline while dashboard and CLI blanket-classified everyValueErrorasINVALID_TRANSITION, producing different codes for the same input. The three surfaces now share one implementation; the heuristic retires once Stage 3 introduces typedInvalidTransitionErrorraise sites.- New envelope TypedDicts for future use:
BatchResponse[_T],BatchFailure,ListResponse[_T]. Shape contracts are pinned bytests/api/test_envelope_types.py; consumer wiring lands in Stage 2b. - Typed exceptions
SchemaVersionMismatchError,AmbiguousTransitionError,InvalidTransitionError. Structured carriers for installed/database versions, ambiguous transition candidates, and invalid-transition context respectively. Raise sites land in Stage 2b / Stage 3 — defined here so the exception type and fields are stable. ForeignDatabaseError— runtime guard against cross-project latch-on. Discovery now tracks the first.git/directory it sees during walk-up; if it subsequently finds a.filigree.conf(or legacy.filigree/) above that git boundary, it raisesForeignDatabaseErrorinstead of silently returning the ancestor anchor. The primary failure this prevents: whenfiligreeis installed globally (uv tool install filigree) and an LLM runs commands in a git repo that has no anchor of its own, the old walk-up would dump tickets into whichever parent project's database it found first. The error message tells the caller exactly what to do —cd <project> && filigree initand restart MCP — so the LLM can self-correct rather than corrupting a sibling project's data. Monorepos (conf at the git root) and anchor-less trees (no git in ancestry) remain unaffected.ForeignDatabaseErrorsubclassesProjectNotInitialisedErrorso existing generic "not set up" handlers still catch it, andfiligree doctornow emits the full message as a CheckResult..filigree.conf— JSON anchor file at the project root. The authoritative discovery target: walk-up looks for this file (not the.filigree/directory). Nested.filigree.conffiles override their parents — first hit wins. Carriesversion,project_name,prefix, anddb(path to the database, relative to the conf file).FiligreeDB.from_conf(conf_path)classmethod — open a project DB by its conf anchor.WrongProjectError(ValueErrorsubclass) — raised on write operations against IDs whose prefix doesn't match the open DB's prefix. Catches an agent that climbed into a parent's database and tries to mutate a foreign-prefix ticket. Read methods (get_issue,get_comments, etc.) intentionally do not enforce, so legitimate cross-prefix lookups (migration, history) still work.ProjectNotInitialisedError(FileNotFoundErrorsubclass) — raised when no.filigree.confis found anywhere up to/. Error message points atfiligree initandfiligree doctor.filigree doctorflags~/.filigree.confif present (a conf at$HOMEclaims everything beneath it; almost always a mistake) and reports whether the project's.filigree.confanchor exists.
-
Wire-shape unification (breaking for callers branching on error
code).GET /api/release/{id}/treeon a non-release issue now returnscode: "NOT_FOUND"(was"VALIDATION"). Status remains 404.GET /api/type/{type_name}on an unknown type now returns status 400 +code: "VALIDATION"(was 404 +"NOT_FOUND").details.valid_typeslists the registered types.- MCP
trigger_scanrate-limited responses now emitcode: "CONFLICT"(was"IO"). The blocking run id is indetails.blocking_run_id; the cooldown window is retriable when the blocking run completes. - MCP
restart_dashboardfailure paths now include acodefield (previously absent).ErrorCode.STOP_FAILEDfor the dead-process-won't-die case,ErrorCode.PERMISSIONfor SIGTERM/SIGKILL denied,ErrorCode.INTERNALfor unexpected exception, andErrorCode.IOwhen the spawn succeeds but returns no URL. - MCP
_build_transition_errornow emitscode: "INVALID_TRANSITION"(uppercase) instead of"invalid_transition". Previously sibling emit sites (e.g.reopen_issue) were already uppercase, so a case-sensitive client saw two codes for the same logical error. - MCP scanner error responses (
trigger_scan,trigger_scan_batch) now nest extras (blocking_run_id,available_scanners,scanner,spawn_errors,batch_id,scan_run_ids,per_file,exit_code,log_path,skipped) insidedetailsinstead of as top-level keys. Clients that read these fields must now readpayload["details"]["<key>"]. BatchFailureDetail.codeis now typed asErrorCode(wasstr) and emits uppercase values ("NOT_FOUND","INVALID_TRANSITION","VALIDATION") across every surface that surfaces batch results — dashboardPOST /api/batch/*, MCPbatch_close/batch_update/batch_add_label/batch_add_comment, and CLIclose/reopen/batch-update/batch-close/batch-add-label/batch-add-commentin--jsonmode. Previously batch responses mixed an uppercase top-levelcodewith lowercase per-item codes; clients no longer need to branch on the nesting level.- Dashboard uncaught-exception paths in
GET /api/releases,GET /api/release/{id}/tree, andPOST /api/issue/{id}/commentsnow emitcode: "INTERNAL"(was"IO").IOis reserved for transient I/O failures that a client may retry; these paths catchexcept Exceptionwith aBUG:log and should signal "file a bug" instead. - Dashboard
HTTPExceptionenvelope now maps 401 and 422 explicitly (401 →PERMISSION, 422 →VALIDATION). Previously any unmapped status coerced toINTERNAL, which misled clients into treating FastAPI's default 422 validation as a server bug. Unknown status codes still fall back toINTERNALbut now log a warning so operators can discover new mappings.
-
filigree initwrites.filigree.confalongside.filigree/. -
Discovery is split:
find_filigree_confis strict (returns the conf path or raises) andfind_filigree_anchorwalks up for either a.filigree.confor a legacy.filigree/directory, returning(project_root, conf_path_or_None). Both are pure reads — discovery never writes. Legacy installs are still discoverable; the conf is created only by explicit init/install paths so inspection commands work on read-only mounts. -
find_filigree_rootcontinues to return the literal.filigree/directory next to the project anchor, regardless of any customdblocation declared in the conf. -
FiligreeDB.from_projectnow resolves viafind_filigree_anchor, falling back tofrom_filigree_dirfor legacy installs. -
Error messages for "project not initialised" now point at
filigree initandfiligree doctorexplicitly.
-
filigree-7840eae0bd: agents in a directory with no
.filigree/would silently walk up into a parent's.filigree/and write tickets into the wrong DB. Mitigated by the explicit.filigree.confclaim model plus theWrongProjectErrorwrite guard. -
WrongProjectErrorno longer rejects legitimate IDs from projects whose prefix contains a hyphen. The check is now anchored onstartswith(prefix + "-")instead of splitting the ID on the first-(which broke any project initialised with a hyphenatedcwd.name, e.g.my-app/generating IDs likemy-app-abc1234567). -
Project discovery no longer writes during the walk-up. Previously a legacy install discovered via
find_filigree_conftriggered a.filigree.confbackfill, causingPermissionErrorfor inspection-only commands (filigree list,filigree doctor, MCP startup) on read-only checkouts. -
find_filigree_rootno longer misroutes callers when the conf'sdbfield points outside.filigree/. It now returns the project's.filigree/directory directly, somcp_server,install,dashboard,hooks, and the summary writers operate against the correct database and filesystem location. -
filigree-fe8956fb16:
compact_eventsno longer accepts a negativekeep_recentand silently wipes all archived event history. The core method now raisesValueError, the MCP tool schema enforcesminimum: 0, and the MCP handler validates the argument before dispatch. Defense-in-depth now matches the existing CLI guard. -
filigree-33a938b515: concurrent MCP tool invocations no longer corrupt each other. The MCP SDK dispatches tool calls concurrently (
tg.start_soonper request) andFiligreeDBcaches a singlesqlite3.Connection— a failing mutation'sfinally-block rollback could erase a sibling coroutine's uncommitted writes on the shared connection.call_toolnow acquires a per-FiligreeDBasyncio.Lockaround handler execution and the safety-net rollback, serialising tool calls against the shared connection. -
filigree-78903e4ff7: MCP
register_filewithpath="."(project root) no longer escapes as an uncaughtValueError. The handler now catches the normalization failure and returns a cleaninvalid_patherror response, matching the existing traversal-rejection contract. -
filigree-0911b35955: scan ingestion with
path="."no longer silently persists afile_recordsrow with an empty path._validate_scan_findingsnow re-checks the normalized path and raisesValueErrorwith the per-finding index, symmetric withregister_file's post-normalization guard. -
filigree-fda0e2a340:
FiligreeDB.from_filigree_dirno longer adopts a hardcodedprefix="filigree"whenconfig.jsonis missing or lacks an explicitprefixkey. It now falls back to the project directory's own name — matchingfiligree init's default — so a legacy install whose config was deleted or never fully written doesn't silently open with the wrong identity and reject every write to its own issues. -
filigree-bac0797445:
import_jsonlnow fails fast when the JSONL file references issue IDs whose prefix doesn't match the destination DB. Previously imports preserved source IDs verbatim, creating rows that could be read but never mutated — every guarded write path raisedWrongProjectErroron them. Migration tools that deliberately need to preserve foreign IDs can opt in viaimport_jsonl(..., allow_foreign_ids=True)(orfiligree import --allow-foreign-ids). -
filigree-f863b9d1f8:
filigree dashboard --server-modeno longer overwrites the configured daemon port inserver.jsonwhen the caller omits--port. The Click option now defaults toNone, and server mode resolves--port or read_server_config().portbefore invokingdashboard_main. Omitting--portleaves the persisted config alone; passing one still updates it. -
filigree-ceb2da2411:
filigree dashboard --server-modenow refuses to start whenclaim_current_process_as_daemon()reports a different live daemon is already tracked. Previously the return value was silently discarded and a second server process raced the tracked one for the daemon port. -
filigree-563d5454e9:
verify_pid_ownershipnow distinguishes this project's dashboard from another filigree project's after PID recycling.write_pid_fileembeds the dashboard port in the record;verify_pid_ownershiprequires that--port <N>appear in the live process argv when a port is recorded. Cross-project PID collisions no longer misidentify a foreign dashboard as our own, preventingrestart_dashboardfrom sending SIGTERM to the wrong process. -
filigree-73e909e6cc:
cleanup_stale_pidno longer unlinks a freshly written PID file under TOCTOU. The stale record is now moved aside with an atomic rename, re-verified from quarantine, and either committed (unlinked) or restored if a concurrent writer re-populated it during the check. -
filigree-ea2a1959e1:
ensure_dashboard_runningno longer spawns a second dashboard when a hook fires during startup.write_pid_filenow records astartup_ts; when the recorded PID is alive, ours, and the port isn't yet listening but startup is within a 30-second grace window, the hook reports "initializing" instead of respawning. -
filigree-bff063de18: Repeated in-process
filigree.dashboard.main()calls no longer serve the wrong database. The_db/_project_storemodule globals are cleared on both entry and exit, so a subsequent call in the opposite mode routes through the correct resolver instead of inheriting stale state from the previous run. -
Dashboard input validation cluster (6 P1 bugs):
- filigree-719f0abbb5:
POST /issues,POST /issue/{id}/comments,POST /issue/{id}/claim, andPOST /claim-nextno longer surface non-string body fields as uncaughtAttributeError(500). Each route now rejects non-stringtitle/text/assigneewithVALIDATION_ERROR400 before the value reachesstr.strip()in core. - filigree-6c21f57786:
POST /files/{file_id}/associationsnow type-checksissue_idandassoc_typebefore callingadd_file_association, rejecting non-string values with 400 instead of relying on truthiness. - filigree-2b756a5a44:
PATCH /api/issue/{id}now accepts and forwardsparent_id, so dashboard clients can re-parent or clear ("") an issue via the API. Non-stringparent_idis rejected with 400. - filigree-237bbad946:
GET /api/activity?since=…now normalizes the parsed timestamp to UTC isoformat before running the SQL text comparison. Offset-bearing and naive inputs now compare correctly against the stored UTC-offset column instead of being compared byte-for-byte. - filigree-6e6411daba:
graph_v2_enabledfrom.filigree/config.jsonis now parsed via the same strict bool allowlist as env vars. Previouslybool("false")→Truevia Python truthiness; now"false","0","no"etc. disable the feature as intended. - filigree-37c95a7e51: Malformed
FILIGREE_GRAPH_V2_ENABLED/FILIGREE_GRAPH_API_MODEvalues no longer override a valid config. Invalid env values log a warning and fall back to the project-config value, matching the documented resolution order (explicit → compatibility → feature-flag default).
- filigree-719f0abbb5:
-
Install / lifecycle cluster (3 P1 bugs):
- filigree-3572d3b273:
filigree doctornow resolves the database path from.filigree.confwhen one exists, instead of always inspecting.filigree/filigree.db. Projects with a relocateddb(e.g.db = "storage/track.db") no longer get a false "Missing" DB report and a schema check against the wrong file. Legacy installs without a conf still fall back to the old layout; an unreadable conf surfaces as its own check failure without blocking the DB check. - filigree-83c52565d6:
filigree install --hooksnow repairs stalePreToolUseensure-dashboardcommands in.claude/settings.jsonafter a binary move. Previously the substring match"ensure-dashboard" in cmdshort-circuited the check and left the old absolute path in place, so the PreToolUse guard silently stopped firing. The registration walk now uses_hook_cmd_matches()(the same matcher used forSessionStartupgrades), rewrites any bare-form or stale-absolute-path command to the currentensure_dashboard_cmd, and repairs the enclosingmatcherback tomcp__filigree__.*if it has drifted. - filigree-37b1452e59:
install_codex_mcpno longer corrupts a valid~/.codex/config.tomlwhose[mcp_servers.filigree]header carries trailing whitespace or an inline# …comment. The_upsert_toml_tablereplacement regex now accepts[ \t]*(?:#[^\r\n]*)?between the closing]and the line terminator, so the existing block is replaced in place instead of being left intact while a second (duplicate) block is appended — which would render the file unparseable under tomllib's duplicate-table rule.
- filigree-3572d3b273:
-
CLI error-handling cluster (2 P2 + 1 P2 + 1 P3):
- filigree-25daf4e886:
filigree remove-labelno longer escapes aValueErroras an unhandled traceback when the label is empty, contains control characters, collides with a reserved type name, or uses a reserved auto-/virtual namespace (area:,severity:,scanner:,pack:,lang:,rule:,age:,has:). The command now mirrorsadd-label's handling —try/except ValueErroremits a clean stderr message (or{"error": ...}JSON) and exits 1. - filigree-565ff86495:
filigree remove-depno longer escapesWrongProjectError/ValueErrorfrom_check_id_prefixas an unhandled exception. Passing a foreign-prefix ID (e.g. after copying one from another project's docs) now produces the same clean error shape asadd-dep: plain-text on stderr or{"error": ...}JSON, exit 1.refresh_summary(db)only runs when the mutation actually succeeds. - filigree-62c5b61f68:
cli_common.refresh_summary()is now best-effort end-to-end. Previously onlyFileNotFoundErrorwas suppressed, so anOSErrorfromsummary.write_summary'smkstemp/os.replace(disk full, permission denied, cross-device rename) turned a successfulfiligree create --json— which had already committed and printed its JSON result — into a non-zero exit.refresh_summarynow logsOSErroras a warning and broadExceptionwith traceback, matching the MCP server's best-effort pattern. - filigree-3c4196854b:
cli_common.get_db()now surfaces corrupt.filigree.conf, unreadable database, and newer-than-CLI schema as cleanClickException-style exits (stderr + exit 1) rather than leaking rawValueError/OSError/sqlite3.Errortracebacks from every CLI command. Previously onlyProjectNotInitialisedErrorwas handled;read_conffailures on malformed JSON or missingprefix/dbkeys, andinitialize's "schema newer than CLI"ValueError, propagated unhandled.
- filigree-25daf4e886:
-
Core API input-validation cluster (1 P1 + 2 P2 + 1 P3):
- filigree-0903743222:
archive_closed(days_old=-1)no longer silently archives every freshly closed issue by computing a future cutoff timestamp. The core method now rejectsdays_old < 0withValueError, the MCParchive_closedschema declaresminimum: 0, and the MCP handler runs_validate_int_rangebefore dispatch — defense-in-depth now matches the existing CLIclick.IntRange(min=0)guard. - filigree-7c1932b74e:
create_issue/update_issueno longer persist whitespace-onlyassigneevalues thatclaim_issuewould then treat as "already assigned". Assignees are now normalised via_normalize_assignee(strip to either""or a trimmed real identity) before insert/update, and non-string values raiseTypeErrorinstead of being stored raw. Whitespace-only input now silently normalises to unassigned, so a subsequentclaim_issuesucceeds instead of reportingalready assigned to ' '. - filigree-0b4fcb6d30:
create_issue(labels="urgent")no longer iterates the bare string character-by-character —"urgent"would previously yield one-char labels"u","r","g", … — andcreate_issue(deps="abc")no longer emits a misleading "Invalid dependency IDs" error fora/b/c. Bothlabelsanddepsare now validated up front with_validate_string_list, so a bare string or mixed-type iterable raisesTypeErrorwith a clear message. - filigree-39c410ef92:
filigree labels --top -1no longer behaves as "unlimited" despite the help text advertising0as the unlimited sentinel. The option type is nowclick.IntRange(min=0), matching the MCPlist_labels.topschema.
- filigree-0903743222:
-
Dashboard lifecycle cluster (1 P1 + 2 P2):
- filigree-2298877675:
restart_dashboard(MCP) no longer reportsstatus: "restarted"when the old dashboard never exits. The SIGTERM path previously setstopped = Trueunconditionally after the 2-second grace wait; if the process was wedged,ensure_dashboard_runningwould then reuse the same still-alive process and the tool happily labelled the no-op a successful restart. The handler now pollsis_pid_aliveafter the grace window, escalates to SIGKILL with a second grace window when needed, and returns{"code": "stop_failed"}instead of proceeding to respawn when the old PID genuinely refuses to die. - filigree-89e7a1c833:
ensure_dashboard_runningno longer leaves a detached dashboard running untracked whenwrite_pid_file/write_port_fileraises OSError after a successfulPopen(..., start_new_session=True). The metadata writes are now wrapped in try/except; on failure the just-spawned child is terminated (SIGTERM then SIGKILL escalation with bounded waits), any partial PID/port files are unlinked, and the function returns a clean"Failed to record dashboard metadata: …"error instead of propagating the exception and orphaning the session-detached process. - filigree-aa80d21b97:
_doctor_ethereal_checksnow usesverify_pid_ownership(liveness + argv identity + recorded-port match) instead of rawis_pid_alivewhen evaluating the Ephemeral PID check. Previously a PID that had been recycled to an unrelated process passed the check as a healthy dashboard; now the same record is reported as stale, matching the existing ownership semantics used bycleanup_stale_pid,ensure_dashboard_running, andrestart_dashboard.
- filigree-2298877675:
-
Planning cluster (3 P2 bugs):
- filigree-fcac6acf6c:
create_planno longer emits duplicatedependency_addedevents when a step'sdepslist repeats the same index (e.g.deps: [0, 0]). The dep row write already usesINSERT OR IGNORE, but the event write was unconditional, so duplicate events piled up in the audit log and wedgedundo_last()— the sibling duplicate looked like a fresh reversible event the undo machinery couldn't clear. The event is now only recorded whencursor.rowcount > 0, matchingadd_dependency's semantics. - filigree-a5e7090f76:
create_plannow rejects out-of-range or non-integerpriorityvalues with a cleanValueErrorup front instead of letting them slip through to the DB-layerCHECK (priority BETWEEN 0 AND 4)and surface as an uncaughtsqlite3.IntegrityErrortraceback at the CLI. Validation runs before the transaction begins at all three levels (milestone, phase, step); booleans are rejected explicitly sinceboolis a subclass ofint. - filigree-6b0f8cfb49:
filigree plan <milestone>now derives phase and step markers fromstatus_category(open/wip/done), matching the pattern already used bysummary.py. Previously the CLI hardcoded the legacyopen/in_progress/closedraw status names, so the built-in planning workflow (pending → in_progress → completedfor steps;pending → active → completedfor phases) rendered every pending step as[?]and never showed the[WIP]marker for an active phase.
- filigree-fcac6acf6c:
-
CLI
--jsonoutput cluster (2 P1 + 1 P2):- filigree-7676d82aa2:
filigree guide <pack> --jsonno longer emitsguideas a stringifiedmappingproxy(...)literal.WorkflowPack.guideis aMappingProxyTypethatjson.dumpscannot serialise, and the old call'sdefault=strescape hatch silently stringified the whole mapping. The CLI now converts viadict(pack.guide)(matching the MCP handler), echoes the canonicalpack.packid, and dropsdefault=strso future unserialisable fields raise instead of silently stringifying. - filigree-5e3e587396:
filigree close <ids...> --jsonnow reports only newly unblocked issues inunblocked, matching the documented contract (docs/cli.md) and the MCPclose_issueimplementation. The previous code snapshotteddb.get_ready()after the close and excluded closed IDs, so any pre-existing ready issue was falsely claimed as newly unblocked — scripts coordinating work off this payload would re-claim/notify on work that wasn't actually released. The handler now capturesready_beforebefore the close loop and returnsready_after - ready_before. - filigree-89ab20068d:
filigree type-info <type> --jsonnow emits the full field schema (options,default,required_at) instead of dropping those keys. The CLI previously rebuilt the dict inline and omitted every field attribute exceptname/type/description/pattern/unique, so callers reading enum options (e.g.severity.options = ["critical", "major", ...]) or defaults via--jsonsaw nothing. The CLI now delegates to the canonicalFiligreeDB._field_schema_to_info()serialiser that the MCPget_type_infohandler already uses.
- filigree-7676d82aa2:
-
Timestamp handling cluster (1 P1 + 3 P2):
- filigree-a693bdfab2:
migrate._safe_timestamp()re-runs no longer synthesize a freshdatetime.now(UTC)for every invalid/blank source timestamp. Event and comment dedup keys both includecreated_at, so non-deterministic fallbacks produced a new key on every re-migration and silently duplicated rows._safe_timestampnow takes an optional stablefallbackandmigrate_from_beadsthreads each issue's already-normalizedupdated_atas the fallback for its events and comments — dedup is now idempotent across runs. - filigree-be53912410:
migrate_from_beadsno longer importsclosed_atraw. Non-done source issues now haveclosed_atcleared (beads doesn't clear it on reopen — filigree does), and done-category issues with blank/malformedclosed_atfall back to the issue's normalizedupdated_at. Downstream code (archive_closed's SQLclosed_at < cutoff,analytics.lead_time'sdatetime.fromisoformat) can now assume a parseable ISO timestamp orNULL. - filigree-51ad2aa743:
cycle_timeandget_flow_metricsno longer sortstatus_changedevents via SQLORDER BY created_at ASC. Imported/migrated rows can carry heterogeneous ISO offsets (e.g.+00:00vs+10:00); lexical ordering placed chronologically-earlier+10:00events after+00:00events, picking the wrong WIP→done pair. A new_sort_events_chronologicallyhelper parses eachcreated_atto UTC, drops unparseable rows, and orders by(parsed_utc, id)in Python. - filigree-735977d7bc:
filigree changes --since <ts>now normalizesZ→+00:00and validates the input withdatetime.fromisoformatbefore passing it to SQLite. Stored events usedatetime.now(UTC).isoformat()which emits+00:00; aZ-suffixed--sincewould miscompare lexically against fractional-second stored rows, silently dropping matching events. Malformed input now produces a cleanstderrerror and exit 1 instead of a silent empty result.
- filigree-a693bdfab2:
-
Scan subsystem cluster (2 P1 + 2 P2):
- filigree-ed3be5a092:
trigger_scan/trigger_scan_batchnow reserve apendingscan_runrow before spawning the scanner process, closing the TOCTOU gap betweencheck_scan_cooldownandcreate_scan_run. Concurrent triggers previously both read "no recent run", both spawned a scanner, and both recorded independent runs — bypassing the 30-second rate limit. The newreserve_scan_runwraps the cooldown read and the row insert in a singleBEGIN IMMEDIATEtransaction, so the second caller blocks on the writer lock, then sees the reservation and returnsrate_limited. Spawn failures transition the reservation tofailedso retries aren't blocked by a dead reservation. - filigree-ec33df4b86:
trigger_scan_batchnow assigns each file its ownscan_run_idinstead of sharing one id across every spawned child. Per-file scanners invoked with--max-files 1each run their own completion POST; with a shared id the fastest child finalised the batch while siblings were still scanning, so later findings landed against acompletedrun. The handler now returnsbatch_idplus ascan_run_idslist and aper_filebreakdown (pid, log path, file id per child), and each child's lifecycle is tracked independently. Repeated file paths in one request are deduped before reservation. - filigree-daefeda81d:
get_scan_statusnow resolves scanner log paths against an explicitFiligreeDB.project_rootset byfrom_filigree_dir/from_conf, instead of assumingdb_path.parent.parent. The old derivation broke for any.filigree.confinstall with a relocateddb(e.g.db = "storage/db/track.db"):db_path.parent.parentlanded onstorage/, solog_tailwas always empty because the resolved path pointed at a directory that didn't exist. Legacy direct-path construction still falls back to the old derivation. - filigree-f1cce0f474:
_validate_localhost_urlnow requires a parseable URL with anhttporhttpsscheme and an explicit localhost hostname. Previously""was accepted as a valid host andurlparse("no-scheme").hostname or ""coerced malformed URLs back into the empty-string allowlist, so scans reported "triggered" with an unusable callback — the scanner helper'sf"{api_url}/api/v1/scan-results"POST would then fail or target whateverno-scheme/api/v1/scan-resultsresolved to on the box. Empty strings, scheme-less values, and non-HTTP schemes are now rejected before the scanner is spawned.
- filigree-ed3be5a092:
-
Template registry cluster (1 P1 + 2 P2 + 1 P3):
- filigree-5c1605d349:
_infer_status_categoryno longer misclassifies built-in done states asopenwhen the active template registry can't resolve the issue's type — e.g. a pack disabled after issues were created in it, or an import that predates pack registration. The hardcoded 6-name done set missedreleased,completed,mitigated,verified,accepted, and ~20 other done-category states shipped in bundled packs, soclose_issue/reopen_issue/ stats gated on the category resolved areleaseinreleasedas still-open. The fallback now derives a(type, state) → categorymap fromtemplates_data.BUILT_IN_PACKSat module load and is type-aware; state-name-only disambiguation only promotes names whose category is identical across every bundled type that declares them, so ambiguous names likeresolved(wip inincident, done elsewhere) stay ambiguous and fall through toopen. - filigree-910f1cb024:
validate_issueno longer returnsvalid=Truefor issues whose type isn't in the active registry or whose current status isn't a declared state for that type. Both cases are reachable via bulk import, migration, and pack disable after creation; the previous short-circuit ontpl is Noneplus the missing state-membership check meant the CLI and MCPvalidate_issuequietly rubber-stamped structurally broken rows.ValidationResult.errorsnow surfaces both conditions with actionable messages listing the valid state names. - filigree-5c9f9aa7c2: MCP
reload_templatesno longer propagatesValueErroras an internal server error when.filigree/config.jsonis corrupt._refresh_enabled_packsraises on malformed JSON, andmcp_server.call_toolre-raises unhandled exceptions — the handler now catchesValueErrorand returns a structuredvalidation_errorresponse, matching the contract of every other MCP tool. - filigree-33e7bf9947: MCP
reload_templatesnow calls_refresh_summary()after a successful reload, socontext.mdreflects the new enabled-packs state. Every other MCP mutation refreshes the summary; this one skipped, so theIn ProgressandNeeds Attentionsections (both template-derived) went stale until the next unrelated mutation.
- filigree-5c1605d349:
- The
classicgeneration at/api/v1/*continues to work unchanged. Existing 1.x integrations require no code changes for filigree 2.0 compatibility at the HTTP surface. ADR-002 §8 specifies the retirement process — none planned.
classicgeneration: frozen indefinitely. Retirement requires a new ADR (ADR-002 §8).loomgeneration: stable contract. Additions must preserve wire compatibility; breaking evolution introduces a new named generation.- MCP and CLI: reflect the living surface. Shape evolves alongside filigree releases; pinning consumers should use HTTP generations.
- Schema versions: forward-migration only. Downgrade is not supported;
schema-mismatch surfaces as
ErrorCode.SCHEMA_MISMATCH(CLI / dashboard / MCP) or a stderr warning +.filigree/INSTALL_VERSIONmarker (filigree init).
1.6.1 - 2026-04-01
filigree doctorno longer reports a false "duplicate install" warning when running from a uv tool venv whose Python is symlinked to a uv-managed interpreter outside the venv
1.6.0 - 2026-03-30
- Codex MCP install now always writes global stdio config with runtime project autodiscovery instead of project-pinned
--projectargs or URL-based routing - Claude Code stdio MCP install now also uses runtime autodiscovery (
args = []) so folder switches do not leave stale project targets behind - Installation and migration docs now describe autodiscovery-based MCP wiring and correct the remaining MCP tool-count references to 71
filigree doctornow rejects deprecated Codex URL routing and stale project-pinned Codex config with a clearer remediation message- Server-mode Codex installs no longer write daemon URLs that can misroute writes across workspaces
- Updated install, doctor, and CLI-admin coverage for autodiscovery-based Claude Code and Codex MCP config
1.5.2 - 2026-03-23
- README accuracy — MCP tool count corrected from 53 to 71; ruff line-length corrected from 120 to 140
- Accessibility — added
aria-labelattributes torole="button"elements in dashboard detail panel (blocker links, downstream links, file links) - XSS defense — tour tooltip text now escaped via
escHtml()(was safe from constants, now safe by construction) - CLI help text —
reopencommand clarifies it returns issues to their type's initial state, not previous state - Ruff formatting applied to 5 source files that had drifted
- New
tests/test_dashboard.py— 25 tests coveringProjectStoreinit/load/corruption, idle watchdog, idle tracking middleware,_get_dberror paths, ethereal vs server mode app creation - New
tests/test_doctor.py— 70 tests coveringCheckResult,_is_venv_binary,_is_absolute_command_path, config/DB/context/gitignore/MCP/hooks/skills/instruction file checks - Expanded
tests/api/test_scanner_tools.py— 36 new tests (was 2) covering scan run CRUD, status transitions, cooldown logic, batch runs, log tailing, edge cases
1.5.1 - 2026-03-18
- Label taxonomy system — namespace reservation, virtual labels (
age:fresh,age:stale,has:findings,has:plan,has:dependencies), array labels, prefix search (--label-prefix=cluster/), and not-label exclusion inlist_issues - MCP tools for label discovery:
list_labelsandget_label_taxonomy - CLI commands:
filigree labels,filigree taxonomy,--label-prefix,--not-label, repeatable--labelonlist - Mutual exclusivity enforcement for
review:namespace labels - Scanner lifecycle tracking —
scan_runstable with schema v7→v8 migration,ScansMixinwith CRUD, cooldown checks, and status transitions - Finding triage tools —
get_finding,list_findings(global),update_finding(file_id optional),dismiss_finding,promote_finding,batch_update_findingsMCP tools - Scanner module extraction — new
mcp_tools/scanners.pywithtrigger_scan_batch,get_scan_status,preview_scan; DB-persisted cooldown replaces in-memory dict - Shared scanner pipeline —
run_scanner_pipeline()inscripts/scan_utils.pywith argparse integration, batch orchestration, and API completion logic; slimmedclaude_bug_hunt.pyandcodex_bug_hunt.py - Scanner config file:
.filigree/scanners/claude-code.toml
- Breaking (API):
POST /api/v1/scan-resultsresponse replacesissues_created/issue_idswithobservations_createdcount. Thecreate_issuesparameter is replaced bycreate_observations. - Breaking:
update_findingsignature changed —file_idis now keyword-only and optional process_scan_resultsreplacescreate_issueswithcreate_observationsfor lightweight triage- Narrowed
except Exceptionto specific exception types in scanner MCP handlers to avoid masking programming errors as DB failures batch_update_findingsresponse now includes"partial": trueflag when some updates succeed and others failScanIngestResultnow tracksobservations_failedcount and reports per-finding failure messages- Batch scan data warning now distinguishes files from processes
process_scan_resultsterminal-state detection uses direct DB query instead of brittle string matching
batch_update_findingsnow logs individual failure warnings server-side (previously only in MCP response)promote_finding_to_observationsurfaces a note when file record is missing instead of silently losing contextprocess_scan_resultsdocstring corrected:severityis optional (defaults to"info"),suggestionadded to optional fields_handle_get_scan_status,_handle_dismiss_finding,_handle_list_labels, and_handle_get_label_taxonomynow catchsqlite3.Errorinstead of returning raw exception traces- Scanner batch file report read wrapped in try/except so one corrupt file no longer kills the entire batch
- Scan-run completion POST failure now counted in
api_failuresfor correct exit code - Fragile parallel-list index coupling in batch scan replaced with
zip(..., strict=True) - Unused variable lint violation in test_scans.py
- 6 new test files:
test_scans.py,test_finding_triage.py,test_label_discovery.py,test_label_query.py,test_scanner_lifecycle_tools.py,test_finding_triage_tools.py - Test for breaking
create_issues→create_observationsparameter rename - Test for
update_findingwith mismatchedfile_idraisesKeyError - Parametrized severity-to-priority mapping tests for all 5 severity levels
- Security boundary tests: path traversal, non-localhost URL rejection, reserved namespace enforcement
1.5.0 - 2026-03-09
- Observations subsystem — fire-and-forget agent scratchpad with TTL expiry, audit trail, atomic promote-to-issue, and file anchoring (schema v6→v7 migration)
- MCP tools for observations:
observe,list_observations,dismiss_observation,promote_observation - Observation awareness in session context, project summary, and MCP prompt
- Observation triage workflow with promote-to-type selection and requirements pack support
- Dashboard observation stats on Insights page and observation counts in Files table and detail panel
- Kanban List mode with sortable table view
- Scoped subtree explorer replacing the standalone Graph tab — sidebar-driven, renders parent-child hierarchy edges
- Consolidated dashboard from 7 tabs to 5: Activity merged into Insights, Health merged into Files as collapsible Code Quality Overview, Workflow demoted to Settings modal
- Redesigned header filter bar with status pills, Done time-bound dropdown, and cleaner layout
- Decomposed
process_scan_resultsmonolith into focused helpers with table-drivenexport_jsonl - Simplified TypedDict patterns using
PlanResponseinheritance andNotRequired - Breaking (MCP):
get_valid_transitionsandget_issuemissing_fieldsnow returns bare field name strings instead of full schema objects — consumers expecting{name, type, description}dicts must update to plainlist[str] - Threaded
Severity/FindingStatus/AssocTypeLiteral types through API signatures
- Codex MCP install and doctor now validate the config Codex actually uses (
~/.codex/config.toml), rewrite stalefiligreeentries that still target another project, and support server-mode MCP URL installs - Restored schema
v6compatibility for historical databases by reinstating the missingv5 -> v6migration for theissues.parent_idself-foreign-key, including FTS rebuild handling after the table swap - JSONL export/import now round-trips the file subsystem (
file_records,scan_findings,file_associations,file_events), reconciles the seededFuturerelease singleton on restore, and makesmerge=Trueidempotent for imported comments and file history rows - Ethereal/server lifecycle helpers now degrade cleanly under restricted socket permissions, treat
PermissionErrorliveness checks as live processes, and verify PID ownership against the expected dashboard command shape before reusing or stopping processes - Older Filigree binaries now refuse to open databases with a newer schema version instead of silently attempting an unsupported downgrade path
- Dashboard issue creation now preserves custom
fields, so release/version metadata and other template-backed values survivePOST /api/issues - CLI, dashboard, hooks, and MCP project openers now honor configured
enabled_packsinstead of silently falling back to the default pack set - File lookups by path now normalize equivalent path spellings on read, matching the write-time identity rules used by scan ingestion and file registration
- Transaction safety hardening: rollback guards on promote/close, savepoint leak fixes, undo race conditions, and phantom write prevention
- Template engine hardening: reverse-reachability BFS validation, crash-on-anomaly for category cache, rejection of unknown types in transitions and initial state lookups.
get_moderaisesValueErrorfor unknown modes (all callers already handle this).get_initial_stateraisesValueErrorfor unknown types (callers guard upstream or propagate correctly).list_issuesraisesValueErrorfor negative limit/offset (API schema prevents negative values at boundary). - TOCTOU race fixes in PID ownership and cleanup, unchecked return codes in OS command reads
- Numerous type-safety fixes: generic
PaginatedResult, typed observations and planning responses,EventTypeLiteral enforcement at SQL boundary - CLI runtime fixes: partial-failure data loss prevention, correct exit codes, and
--jsonsupport for all commands - Issue creation/update now reject non-dict
fieldsinputs with a stable validation error instead of crashing with an internalAttributeError - Dashboard issue create/update and batch update endpoints now translate invalid non-dict
fieldspayloads into400 VALIDATION_ERRORresponses instead of leaking500errors - Dashboard filter composability: type filter and cluster mode now work together correctly
- Shape contract tests for 14 MCP handler response TypedDicts
- 42 new tests for previously untested error paths and edge cases
- DB core test gap closure for transactions, cycle detection, and import paths
1.4.1 - 2026-03-03
- Dashboard (
fastapi,uvicorn) is now part of core dependencies — no morefiligree[dashboard]extra required
filigree initon existing installs now reports schema migrations ("Schema upgraded v1 → v5") instead of silently applying themfiligree doctor --fixcan now auto-repair outdated database schemas (was missing from the fixable check map)- Dashboard broken by Tailwind CSS CDN SRI integrity hash mismatch — removed incompatible SRI attribute from dynamic CDN resource
1.4.0 - 2026-03-01
Architectural refactor: decompose monolithic modules into domain-specific subpackages, add type safety with TypedDicts, boundary validation, releases tracking, and comprehensive test restructuring.
not_a_bugdone-state for bug workflow — distinct fromwont_fixfor triage rejections (transitions fromtriageandconfirmed)retiredstate added to release workflow with quality-check refinements
- Click-to-copy on issue IDs in kanban cards and detail panel header (hover underline, toast feedback, keyboard accessible)
- "Updated in last X days" dropdown filter in the main issue toolbar (1d, 7d, 14d, 30d, 90d) — persisted with other filter settings
- Sticky headers for metrics, activity, files, and health views (header stays visible while content scrolls)
namefield inProjectConfig/.filigree/config.json— separates human-readable project name from the technical ID prefixfiligree init --nameoption to set display name independently of--prefix- Dashboard title and server-mode project list now use
namewith fallback toprefix
FiligreeDBdecomposed into domain mixins:EventsMixin,WorkflowMixin,MetaMixin,PlanningMixin,IssuesMixin,FilesMixin— each in its own module undersrc/filigree/DBMixinProtocolwired into all mixins, eliminating 33type: ignorecomments- CLI commands split from monolithic
cli.pyintocli_commands/subpackage - MCP tools split into domain modules
- Dashboard routes split into
dashboard_routes/subpackage install.pysplit intoinstall_support/subpackage
- Plugin system & language packs design document added with 8-specialist review consensus
- ADR-001 superseded in favour of workflow extensibility design
- Issue ID format documentation corrected from
{6hex}to{10hex}
- Issue ID entropy increased from 6 to 10 hex characters to reduce collision probability at scale
import_jsonlusescursor.rowcountfor all record types — accurate counts for merge dedup- Batch error reporting enriched with
codeandvalid_transitionsfields - Stale
filigree[mcp]extra removed from packaging; WMIC parsing made quoting-aware for Windows compatibility - PID verification abstracted beyond
/procfor cross-platform support fcntl.flock()replaced withportalockerfor cross-platform file locking- Dead code
_generate_id_standalone()removed
1.3.0 - 2026-02-24
Server/ethereal operating modes, file intelligence + scanner workflows, Graph v2, and broad safety hardening.
filigree init --modeandfiligree install --modefor explicit ethereal/server setup- Server-mode config and registration system with schema-version enforcement
- Server daemon lifecycle commands and process tracking helpers
- Deterministic port selection and PID lifecycle tracking with atomic writes
- Streamable HTTP MCP endpoint (
/mcp/) for server mode - Session context now includes dashboard URL
- Mode-aware doctor checks for ethereal/server installations
- File records and scan findings workflow with metadata timeline events
- Files and Code Health dashboard views (file list/detail/timeline, hotspots, health donut/coverage)
- Split-pane findings workflow and live scan history in dashboard
- Scanner registry loaded from TOML configs in
.filigree/scanners/ - New MCP tools:
list_scannersandtrigger_scan - Scanner trigger support for
scan_run_idcorrelation - Optional
create_issuesflow for scan ingest to promote findings into candidatebugissues and createbug_infile associations - Scan ingest stats extended with
issues_createdandissue_ids - CLI init support for scanner directory creation
- Shared scanner utilities and Claude scanner integration
- Kanban cards now display a left-edge colour band indicating issue type (bug=red, feature=purple, task=blue, epic=amber, milestone=emerald, step=grey)
- Graph v2 shipped with improved focus/path workflows and traversal behavior
- Time-window filter with persisted default
- Progressive-disclosure toolbar with grouped advanced controls
- Improved interaction diagnostics and plain-language status messaging
filigree install --codex-skillsto install Codex skills into.agents/skills/- Doctor health check for Codex skills installation state
- Dashboard frontend restructured from monolithic HTML script to ES-module architecture
- Dashboard behavior split by mode: ethereal uses simplified single-project flow; server mode uses
ProjectStoremulti-project routing - API errors standardized, schema discovery surfaced, and instruction generation extracted for reuse
filigree server registerandfiligree server unregisternow trigger daemon reload when server mode is already running- Scanner command validation now resolves project-relative executables (for example
./scanner_exec.sh) during trigger checks - Install instruction marker parsing improved to tolerate missing metadata/version fields
- Release workflow pack now enabled by default for all new projects alongside core and planning;
suggested_childrenfor release type expanded to include epic, milestone, task, bug, and feature - ADR-001 added documenting the structured project model (strategic/execution/deliverable layers)
- README/docs expanded with architecture plans, mode guidance, and dashboard visuals
- Stale comments and docstrings fixed across 10 source files: endpoint counts, module docstrings, internal spec references (WFT-*), naming discrepancies, and misleading path references all corrected or removed
- Dashboard XSS sinks fixed across detail, workflow, kanban, and move-modal surfaces
- File view click-handler escaping fixed for issue IDs containing apostrophes
- All onclick handlers in detail panel, activity feed, and code health views now use
escJsSingle()for JS string contexts — fixes 6+ XSS injection points whereescHtml()was misused or escaping was missing entirely - HTTP MCP request context isolation fixed for per-request DB/project directory selection
- Issue type names now reserved from label taxonomy to prevent collisions
- Duplicate workflow transitions (same
from_state -> to_state) now rejected at parse and validation time — previously silently accepted with inconsistent dict/tuple behavior - Enforcement value
"none"rejected from templates — only"hard"and"soft"are validEnforcementLevelvalues - Release
rolled_backstate recategorized fromdonetowip— allows resumption transition todevelopment, matching theincident.resolvedfix pattern ProjectStore.get_db()guarded againstUnboundLocalErrorwhenread_config()fails before DB initializationFindingStatustype alias aligned with DB schema — addedacknowledgedandunseen_in_latest, removed stalewont_fixandduplicate- Dead
_OPEN_FINDINGS_FILTER_Fand duplicate_VALID_SEVERITIESclass attributes removed fromFiligreeDB
- Multi-project reload and port consistency hardened in server mode
- Reload failures now surface as
RELOAD_FAILEDinstead of reporting a false-success response unregister_projectupdates locked to prevent concurrent config races- Daemon ownership checks fixed for
python -m filigreelaunch mode - Portable PID ownership fallback added when command-line process inspection is unavailable
- Registry fallback key-collision handling corrected
- Hook command resolution hardened across installation methods
read_server_config()now validates JSON shape and types: non-dict top-level returns defaults, port coerced to int and clamped to 1–65535, non-dict project entries dropped- Invalid port values in server config now log at WARNING before falling back to default (previously silent coercion)
start_daemon()serialized withfcntl.flockonserver.lockto prevent concurrent start racesstart_daemon()anddaemon_status()verify PID ownership viaverify_pid_ownership()— stale PIDs from reused processes no longer cause false "already running" or false statusstart_daemon()wrapssubprocess.Popenintry/except OSErrorto return a cleanDaemonResultinstead of propagating raw exceptions while holding the lockstop_daemon()verifies process death after SIGKILL and reports failure when the process survives; PID file cleaned up in all terminal paths to prevent permanent stuck stateclaim_current_process_as_daemon()now verifies PID ownership before refusing to claim — a reused PID from a non-filigree process no longer blocks the claimstop_daemon()catchesProcessLookupErroron SIGTERM when the process dies between the liveness check and the signal delivery- Off-by-one in
find_available_port()retry loop — now triesbase + PORT_RETRIEScandidates as documented setup_logging()now removes and closes staleRotatingFileHandlers whenfiligree_dirchanges — prevents handler leaks and duplicate log writes in long-lived processes- Session skill freshness check now covers Codex installs under
.agents/skills/in addition to.claude/skills/
_parse_toml()now distinguishesOSErrorfromTOMLDecodeErrorwithexc_info— unreadable scanner TOML files no longer silently vanish fromlist_scanners- Scanner paths canonicalized; datetime crash fixed; command templates expanded
- Scan API hardened (
scan_run_idpersistence, suggestion support, severity fallback) - Findings metadata persistence corrected for create/update ingest paths
- Metadata change detection fixed to compare parsed dictionary values
min_findingsnow counts all non-terminal finding statuseslist_filesfilter validation and project-fallback detail-state behavior corrected/api/v1/scan-resultsnow enforces boolean validation forcreate_issuesscan_sourcevalidated as string in/api/v1/scan-results— non-string values return 400 instead of crashing- Pagination
limitandoffsetenforce minimum values (limit >= 1,offset >= 0) across all API endpoints — prevents SQLiteLIMIT -1unbounded queries trigger_scancooldown set immediately after rate-limit check (before any await) and rolled back on failure — closes check-then-act race windowprocess_scan_results()validatespath,line_start/line_end, andsuggestiontypes upfront with clear error messages instead of crashing in SQL/JSON operationsadd_file_associationpre-checks issue existence and returnsnot_foundinstead of misclassifying asvalidation_error
- Flow metrics now batch status-event loading to remove N+1 event-query behavior
- Graph toolbar overflow/stacking/disclosure behavior corrected across Graph v2 iterations
- Graph controls hardened for inactive focus/path states and large-graph zoom readability
- Files API sort-direction wiring and stale detail-selection clearing fixed
- Missing split-pane window bindings restored; async loader error handling tightened
- Flow metrics now include
archivedissues soarchive_closed()results count in throughput - Analytics SQL queries use deterministic tiebreaker (
id ASC) for stable cycle-time computation when events share timestamps list_issuesreturns empty result whenstatus_categoryexpansion yields no matching states, instead of silently dropping the filterimport_jsonlevent branch uses sharedconflictvariable and counts viacursor.rowcountsomerge=Trueaccurately reports 0 for skipped duplicates- Migration atomicity restored for FK-referenced table rebuilds; dashboard startup guard added
- Graph zoom-in no longer jumps aggressively from extreme zoom-out levels —
wheelSensitivityreduced from Cytoscape default (1.0) to 0.15 - Page title reversed from "[project] — Filigree" to "Filigree — [project]"
_read_graph_runtime_config()failure logging elevated from DEBUG to WARNINGapi_scan_runsexception handler narrowed fromExceptiontosqlite3.Error- Tour onboarding text corrected from "5 views" to "7 views" (adds Files and Code Health)
importcommand catchesOSErrorfor filesystem errors — clean message instead of tracebackclaim-nextwrapsdb.claim_next()inValueErrorhandling with JSON/plaintext error outputsession-contextandensure-dashboardhooks now log at WARNING and emit stderr message on failure instead of swallowing at DEBUGread_config()catchesJSONDecodeError/OSError— corruptconfig.jsonreturns defaults with warning instead of cascading crashes- MCP
_build_workflow_textnow separatessqlite3.Error(with actionable "runfiligree doctor" message) from generic exceptions; both log at ERROR - MCP
get_workflow_promptnarrowsexcept RuntimeErrorto only silence "not initialized"; unexpected RuntimeErrors now logged at ERROR generate_session_contextfreshness-check now splits expected errors (OSError,UnicodeDecodeError,ValueError) at WARNING from unexpected errors at ERROR; both includeproject_rootfor debuggabilityProjectStore.reload()DB close errors now log at WARNING (matchingclose_all()) instead of DEBUGcreate_appMCP ImportError now logged at DEBUG withexc_infoinstead of silently swallowed- MCP
release_claimtool description corrected: clarifies it clears assignee only (does not change status) _install_mcp_server_modeprefix-read failure narrowed toJSONDecodeError/OSErrorand elevated to WARNING;_install_mcp_ethereal_modelogsclaude mcp addstderr on failure- Duplicate
_check_same_threadassignment removed fromFiligreeDB.__init__ list_templates()now includesrequired_at,options, anddefaultin field schema — matchesget_template()outputclaim_issue()now records prior assignee asold_valuein claimed event;undo_lastrestores it instead of always blankingSCHEMA_V1_SQLrefactored from brittleSCHEMA_SQL.split()to standalone constant with test assertions for subset integrity
- Priority normalization hardened (
_safe_priority()) — non-numeric and out-of-range values coerced during migration instead of crashing - Timestamp normalization added (
_safe_timestamp()) — NULL/empty timestamps replaced with valid ISO-8601 fallbacks apply_pending_migrations()guarded against being called inside an existing transaction — raisesRuntimeErrorimmediately- Caller's
foreign_keysPRAGMA setting preserved across migrations instead of unconditionally restoring to ON
- Hybrid registration system (
registry.py) removed in favor of explicit mode-based registration paths - Checked-in
.mcp.jsonremoved from version control
1.2.0 - 2026-02-21
Multi-project dashboard, UX overhaul, and Deep Teal color theme.
- Ephemeral project registry (
src/filigree/registry.py) for discovering local filigree projects ProjectManagerconnection pool for serving multiple SQLite databases from a single dashboard instance- Project switcher dropdown in the dashboard header
- Per-project API routing via FastAPI
APIRouter— all endpoints scoped to the selected project - MCP servers self-register with the global registry on startup (best-effort, never fatal)
/api/healthendpoint for dashboard process detection
- Equal-width Kanban columns (
flex: 1 1 0withmin-width: 280px) — empty columns no longer shrink - Drag-and-drop between Kanban columns with transition validation — pre-fetches valid transitions on dragstart, dims invalid targets, optimistic card move with toast confirmation
- Keyboard shortcut
mopens "Move to..." dropdown as accessible alternative to drag-and-drop - Type-filter / mode toggle conflict resolved — Standard/Cluster buttons dim when type filter is active, active filter shown as dismissible pill
- WCAG-compliant status badges — open badges use tinted background with higher-contrast text
- P0/P1 text priority labels — critical and high priorities show text badges instead of color-only dots
- Stale badge click shows all stale issues (not just the first)
- Workflow view auto-selects first type on initial load
- Disabled transition buttons show inline
(missing: field)hints - Claim modal shows "Not you?" link when pre-filling from localStorage
- Header density reduction — removed duplicate stat spans (footer has the full set)
- Settings gear menu (⚙) in header — replaces standalone theme toggle with a dropdown containing "Reload server" and "Toggle theme"
POST /api/reloadendpoint — soft-reloads server state (closes DB connections, re-reads registry, re-registers projects) without process restart
- 20 CSS custom properties on
:root(dark default) and[data-theme="light"]for all surface, border, text, accent, scrollbar, graph, and status colors - 15 utility classes (
.bg-raised,.text-primary,.bg-accent, etc.) for static HTML elements THEME_COLORSglobal JS object for Cytoscape graphs (which cannot read CSS custom properties), synced intoggleTheme()and theme init- Dark palette: deep teal surfaces (#0B1215 → #243A45), sky-blue accent (#38BDF8)
- Light palette: teal-tinted whites (#F0F6F8 → #DCE9EE), darker sky accent (#0284C7)
- Theme toggle mechanism changed from
classList.toggle('light')todataset.themewith CSS[data-theme="light"]selector - All
bg-slate-*,text-slate-*,border-slate-*Tailwind classes eliminated from dashboard - Old
.lightCSS override block (9 lines with!important) removed
- Dashboard API restructured from flat routes to
APIRouterwith project-scoped prefix CATEGORY_COLORS.wipupdated from#3B82F6(blue-500) to#38BDF8(sky-400)CATEGORY_COLORS.doneupdated from#9CA3AF(gray) to#7B919C(teal-tinted gray)@keyframes flashcolor updated to match accent (rgba(56,189,248,0.5))- Sparkline stroke color uses
THEME_COLORS.accentinstead of hardcoded blue
- Cytoscape graph and workflow graph colors now update on theme toggle (re-render triggered)
- Graph legend status dots use CSS custom properties instead of hardcoded hex
- Kanban column header dots use
CATEGORY_COLORSinstead of hardcoded hex - Progress bars in cluster cards and plan view use
CATEGORY_COLORSinstead of hardcoded hex
1.1.1 - 2026-02-20
Comprehensive bug-fix and hardening release. 31 bugs resolved across 13 source files, identified through systematic static analysis and verified against HEAD.
- Template quality checker (
check_type_template_quality()) wired into template load pipeline
_category_cacheuses hierarchical keys matching_transition_cacheconvention- Core
batch_close()return type changed fromlist[Issue]totuple[list[Issue], list[dict[str, str]]]matchingbatch_update()pattern
create_issue()andupdate_issue()restructured to validate-then-write with explicit rollback on failure, preventing orphaned rows/events via MCP's long-lived connectionreopen_issue()wrapped in try/except rollback to prevent orphaned events on failure- MCP
call_tool()safety net: rolls back any uncommitted transaction after every tool dispatch close_issue()respects hard-enforcement gates on workflow transitionsclose_issue()validatesfieldstype before processing
StateDefinition.categoryvalidated at construction time — invalid categories raiseValueError- Duplicate state names detected at both parse and validation time (defense in depth)
enabled_packsconfig validated aslist[str]— strings wrapped, non-lists fall back to defaultsparse_type_template()validates transitions/fields_schema types — raisesValueErrornot rawTypeError- Incident
resolvedstate re-categorized fromdonetowip—close_issue()from resolved now works correctly - Incident workflow guide: stale
resolved(D)notation corrected toresolved(W)in state diagram
- Batch endpoints validate
issue_idsas list of strings — null/missing/non-list values return 400 - Batch close returns per-item
closed/errorsinstead of fail-fast 404/409 - Claim endpoints reject empty/whitespace assignee with 400
- All sync handlers converted to async to fix concurrency race
- Non-string batch IDs rejected with validation error
create-planvalidates milestone/phases types, catchesTypeError/AttributeErrorcreate-plan --filewraps file read in error handling (OSError,UnicodeDecodeError)importcatchessqlite3.IntegrityErrorfor constraint violations- Backend validation errors properly surfaced in
create-planoutput
install_claude_code_mcp()validatesmcpServersis a dict before use- Hook detection handles non-dict/non-list JSON structures throughout
_has_hook_command install_codex_mcp()rejects malformed TOML instead of silently appendingrun_doctor()usesfinallyblock to prevent SQLite connection leaksensure_dashboard_running()checksfastapi/uvicornimports explicitlyensure_dashboard_running()polls process after spawn, captures stderr on failure- Executable path resolution uses
Path.parent / "filigree"instead of string replacement
cycle_time()guards done-scan withstart is not None— no break before WIP foundget_flow_metrics()paginates all closed issues instead of hardcoded 10k caplead_time()accepts pre-loadedIssueobject to avoid N+1 re-fetch
setup_loggingguarded bythreading.Lockto prevent duplicate handlers from concurrent calls- Handler dedup uses
os.path.abspath()normalization to handle symlink aliases
- Comment dedup includes
created_atto preserve legitimate repeated comments - Zero-value filter removed — numeric
0preserved in migrated fields rebuild_table()FK check results read and validated, not silently ignoredrebuild_table()FK fallback hardened withBEGIN IMMEDIATE
- Parent ID lookup chunked in batches of 500 to avoid SQLite variable limit
_sanitize_title()strips control chars, collapses newlines, truncates — prevents markdown/prompt injection
no_limit=truepagination uses 10M effective limit and computeshas_morecorrectly- Spike cross-pack spawns direction corrected to match dependency contract
undo_last()guards against NULLold_valueinpriority_changedevents — returns graceful error instead ofTypeErrorcrashundo_last()guards against NULLnew_valueindependency_addedevents — returns graceful error instead ofAttributeErrorcrash
remove_dependencyendpoint now passesactor="dashboard"for audit trail consistencyupdate_issue,create_issue, andbatch_updatevalidate priority is an integer — returns 400 instead of 500TypeError
batch_closeandbatch_updatevalidate all IDs are strings before processingbatch_updatevalidatesfieldsis a dict (or null) before passing to core
cycle_time()still executes per-issue events query insideget_flow_metrics()loop — lead_time N+1 fixed but cycle_time N+1 remains (tracked as filigree-f34f66)
1.1.0 - 2026-02-18
- Claude Code session hooks —
filigree session-contextinjects a project snapshot (in-progress, ready queue, critical path, stats) at session start;filigree ensure-dashboardauto-starts the web dashboard - Workflow skill pack —
filigree-workflowskill teaches agents triage patterns, sprint planning, dependency management, and multi-agent team coordination via progressive disclosure filigree install --hooksandfiligree install --skillsfor component-level setup- Doctor checks for hooks and skills installation
- MCP pagination — list/search endpoints cap at 50 results with
has_moreindicator andno_limitoverride - Codex bug hunt script for per-file static analysis
- CI workflow is now reusable via
workflow_call— release pipeline invokes it instead of duplicating logic - Release workflow adds post-publish smoke test (installs from PyPI, runs
filigree --version) github-releasejob is idempotent — re-runs fall back to artifact upload instead of failing- Dependency caching enabled across all CI jobs (
enable-cache) - Main branch ruleset now requires lint, typecheck, and test status checks before merge
- Core logic: claim race condition, create_plan rollback, dependency validation
- Analytics: summary, templates, flow metrics bugs
- Error handling: CLI exit codes, MCP validation, dashboard robustness
- Security: migration DDL atomicity, MCP path traversal, release branch guard
- Peripheral modules: migration, install, version robustness
- FTS5 search query sanitization
- File discovery now allows custom exclusion directories
- Batch-size validation and out-of-repo scan root handling
- Dev/internal files excluded from sdist
1.0.0 - 2026-02-16
- First PyPI release — all features from 0.1.0 plus CI/CD pipeline and packaging
0.1.0 - 2026-02-15
- SQLite-backed issue database with WAL mode and convention-based
.filigree/project discovery - 43 MCP tools for native AI agent interaction (read, write, claim, batch, workflow, data management)
- Full CLI with 30+ commands,
--jsonoutput for scripting, and--actorflag for audit trails - 24 issue types across 9 workflow packs (core and planning enabled by default):
- core: task, bug, feature, epic
- planning: milestone, phase, step, work_package, deliverable
- risk, spike, requirements, roadmap, incident, debt, release
- Enforced workflow state machines with transition validation and field requirements
- Dependency graph with cycle detection, ready queue, and critical path analysis
- Hierarchical planning (milestone/phase/step) with
create-planfor bulk hierarchy creation - Atomic claiming with optimistic locking for multi-agent coordination (
claim,claim-next) - Pre-computed
context.mdsummary regenerated on every mutation for instant agent orientation - Flow analytics: cycle time, lead time, and throughput metrics
- Comments, labels, and full event audit trail with per-issue and global event queries
- Session resumption via
get_changes --since <timestamp>for agent downtime recovery filigree installfor automated MCP config, CLAUDE.md injection, and .gitignore setupfiligree doctorhealth checks with--fixfor auto-repair- Web dashboard (
filigree-dashboard) via FastAPI - Batch operations (
batch-update,batch-close) with per-item error reporting - Undo support for reversible actions (
undo) - Issue validation against workflow templates (
validate) - PEP 561
py.typedmarker for downstream type checking