diff --git a/.filigree.conf b/.filigree.conf index 87f01850..945341bd 100644 --- a/.filigree.conf +++ b/.filigree.conf @@ -2,6 +2,6 @@ "version": 1, "project_name": "filigree", "prefix": "filigree", - "db": ".filigree/filigree.db", + "db": ".weft/filigree/filigree.db", "mode": "ethereal" } diff --git a/.filigree/.gitkeep b/.filigree/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19e0563a..70623931 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Documentation - url: https://github.com/tachyon-beep/filigree#readme + url: https://github.com/foundryside-dev/filigree#readme about: Read the documentation before filing an issue diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4055978..71ef47d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ on: workflow_call: workflow_dispatch: inputs: - require_live_clarion: - description: "Run live Clarion integration tests and fail if Clarion is unavailable" + require_live_loomweave: + description: "Run live Loomweave integration tests and fail if Loomweave is unavailable" required: false default: false type: boolean @@ -80,8 +80,8 @@ jobs: .coverage coverage.json - clarion-contract: - name: Clarion Contract + loomweave-contract: + name: Loomweave Contract runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -98,9 +98,9 @@ jobs: tests/api/test_loom_auth.py tests/federation/test_sei_conformance_oracle.py - live-clarion: - name: Live Clarion Integration - if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.require_live_clarion) }} + live-loomweave: + name: Live Loomweave Integration + if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.require_live_loomweave) }} runs-on: ubuntu-latest env: CLARION_STAGING_BASE_URL: ${{ secrets.CLARION_STAGING_BASE_URL }} @@ -112,7 +112,7 @@ jobs: enable-cache: true python-version: "3.13" - run: uv sync --group dev - - name: Run live Clarion checks + - name: Run live Loomweave checks run: | if [ "${{ github.event_name }}" = "schedule" ]; then uv run pytest tests/integration/test_clarion_staging_smoke.py @@ -126,7 +126,7 @@ jobs: docs: name: Docs (build + deploy) runs-on: ubuntu-latest - needs: [lint, typecheck, frontend, test, clarion-contract] + needs: [lint, typecheck, frontend, test, loomweave-contract] permissions: contents: write steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b4fcc7c..85f63c19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: exit 1 fi - live-clarion-release-check: + live-loomweave-release-check: needs: [ci, validate-tag] runs-on: ubuntu-latest permissions: @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Check scheduled Live Clarion integration + - name: Check scheduled Live Loomweave integration env: GH_TOKEN: ${{ github.token }} run: | @@ -63,7 +63,7 @@ jobs: -f status=success \ -f per_page=20 \ --jq '.workflow_runs[] | select(.conclusion == "success") | [.head_sha, .html_url] | @tsv'); then - echo "::warning title=Live Clarion release checklist::Could not inspect scheduled Live Clarion Integration lane history. Confirm it has passed since Clarion contract changes before publishing federation-sensitive changes." + echo "::warning title=Live Loomweave release checklist::Could not inspect scheduled Live Loomweave Integration lane history. Confirm it has passed since Loomweave contract changes before publishing federation-sensitive changes." exit 0 fi @@ -78,13 +78,13 @@ jobs: done <<< "$RUNS" if [ -z "$MATCHED_URL" ]; then - echo "::warning title=Live Clarion release checklist::No successful scheduled Live Clarion Integration lane was found at or after Clarion contract changes ($LAST_CONTRACT_SHA). Run CI manually with require_live_clarion=true or wait for the scheduled lane before publishing federation-sensitive changes." + echo "::warning title=Live Loomweave release checklist::No successful scheduled Live Loomweave Integration lane was found at or after Loomweave contract changes ($LAST_CONTRACT_SHA). Run CI manually with require_live_loomweave=true or wait for the scheduled lane before publishing federation-sensitive changes." else - echo "Scheduled Live Clarion Integration proof: $MATCHED_URL" + echo "Scheduled Live Loomweave Integration proof: $MATCHED_URL" fi build: - needs: [ci, validate-tag, live-clarion-release-check] + needs: [ci, validate-tag, live-loomweave-release-check] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 76dc680d..cfad7022 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ +# Python __pycache__/ *.py[cod] *.egg-info/ +.venv/ + +# Build / distribution dist/ build/ -.venv/ -*.db -*.db-wal -*.db-shm + +# Test / type / lint caches .mypy_cache/ .ruff_cache/ .pytest_cache/ @@ -14,19 +16,60 @@ build/ coverage.json htmlcov/ +# SQLite databases (and sidecar files) +*.db +*.db-wal +*.db-shm +*.db-journal + +# Secrets / local environment +.env +.env.* + # MkDocs build output (deployed via `mkdocs gh-deploy`, never committed) site/ +# Node +node_modules/ + +# Filigree issue tracker (keep the directory marker, ignore its contents) .filigree/* !.filigree/.gitkeep +.filigree.old/ + +# Weft store (3.0 federation layout) — machine-owned local state; track only +# the shareable scanner configs, mirroring the legacy .filigree/ convention. +# The live DB is already covered by the global *.db / *.db-wal / *.db-shm rules. +.weft/filigree/* +!.weft/filigree/scanners/ +.weft/filigree/scanners/* +!.weft/filigree/scanners/*.toml + +# Agent / tooling config (machine- or user-local) .claude/ -node_modules/ -.mcp.json .codex/ .agents/ .clarion/ clarion.yaml -.filigree.old/ +.mcp.json + +# Agent instruction files (untracked per-checkout) +AGENTS.md +CLAUDE.md +.loomweave/instance_id +loomweave.yaml +wardline.yaml +.loomweave/loomweave.lock +.loomweave/ephemeral.port # Filigree issue tracker .filigree/ + +# Filigree issue tracker +.weft/ + +# Legis — local working dir / config (regenerated/local; never commit) +.weft/legis/ + +# Internal codebase-audit scratch notes — agent working artifacts, never shipped +READ_ONLY_CODEBASE_AUDIT_*.md diff --git a/.loomweave/.gitignore b/.loomweave/.gitignore new file mode 100644 index 00000000..e861d9e4 --- /dev/null +++ b/.loomweave/.gitignore @@ -0,0 +1,26 @@ +# Loomweave .gitignore — ADR-005 tracked-vs-excluded list. +# Tracked (committed): loomweave.db, config.json, .gitignore itself. +# Excluded (ignored): WAL sidecars, shadow DB, per-run logs, tmp scratch. + +# SQLite write-ahead files never belong in the repo. +*-wal +*-shm +*.db-wal +*.db-shm + +# Shadow DB intermediate (ADR-011 --shadow-db). +*.shadow.db +*.db.new + +# Semantic-search embeddings sidecar (ADR-040): large + rebuildable, never +# committed (keeps loomweave.db unbloated). WAL files are covered by *.db-wal/-shm. +embeddings.db + +# Scratch / temp space. +tmp/ + +# Per-run log directories (see detailed-design §File layout). The run dir +# metadata (config.yaml, stats.json, partial.json) is tracked; only the +# raw LLM request/response log is excluded. +logs/ +runs/*/log.jsonl diff --git a/.loomweave/config.json b/.loomweave/config.json new file mode 100644 index 00000000..d7ef3efe --- /dev/null +++ b/.loomweave/config.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "last_run_id": null +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87ada46c..7b6834a6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,3 +5,12 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format + - repo: local + hooks: + - id: wardline-scan + name: wardline scan + entry: wardline scan + language: system + types: [python] + pass_filenames: false + diff --git a/.filigree/scanners/claude-code.toml b/.weft/filigree/scanners/claude-code.toml similarity index 100% rename from .filigree/scanners/claude-code.toml rename to .weft/filigree/scanners/claude-code.toml diff --git a/.weft/filigree/scanners/claude.toml b/.weft/filigree/scanners/claude.toml new file mode 100644 index 00000000..c150a199 --- /dev/null +++ b/.weft/filigree/scanners/claude.toml @@ -0,0 +1,8 @@ +# Generated by 'filigree scanner enable claude'. +# Edit freely; 'filigree scanner disable claude' will require --force if this bundled-name config is modified. +[scanner] +name = "claude" +description = "Per-file bug hunt using Claude CLI" +command = "filigree-scanner-claude" +args = ["--root", "{project_root}", "--file", "{file}", "--max-files", "1", "--api-url", "{api_url}", "--api-token-env", "WEFT_FEDERATION_TOKEN", "--scan-run-id", "{scan_run_id}", "--prompt", "{prompt}"] +file_types = ["py"] diff --git a/.weft/filigree/scanners/codex.toml b/.weft/filigree/scanners/codex.toml new file mode 100644 index 00000000..074afa8e --- /dev/null +++ b/.weft/filigree/scanners/codex.toml @@ -0,0 +1,8 @@ +# Generated by 'filigree scanner enable codex'. +# Edit freely; 'filigree scanner disable codex' will require --force if this bundled-name config is modified. +[scanner] +name = "codex" +description = "Per-file bug hunt using Codex CLI" +command = "filigree-scanner-codex" +args = ["--root", "{project_root}", "--file", "{file}", "--max-files", "1", "--api-url", "{api_url}", "--api-token-env", "WEFT_FEDERATION_TOKEN", "--scan-run-id", "{scan_run_id}", "--prompt", "{prompt}"] +file_types = ["py"] diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 174b75fe..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,127 +0,0 @@ -1. This project uses UV like millions of other projects. Use uv run rather than trying literally nothing and then saying its broken. - -2. Use focused subagents when they materially improve confidence or throughput. - For release reviews, broad audits, multi-surface debugging, and independent - implementation slices, split the work by boundary and dispatch subagents - without asking for another permission round. Keep each subagent prompt - self-contained, give it a narrow scope, avoid overlapping write sets, and - integrate its findings against the live tree before reporting or closing work. - - -## Filigree Issue Tracker - -`filigree` tracks tasks for this project. Data lives in `.filigree/`. Prefer -the MCP tools (`mcp__filigree__*`) when available; fall back to the `filigree` -CLI otherwise. - -### Workflow - -```bash -# At session start -filigree session-context # ready / in-progress / critical path - -# Pick up the next startable issue (atomic claim + transition into its working status) -filigree start-next-work --assignee -# ...or claim a specific issue -filigree start-work --assignee - -# Do the work, commit, then -filigree close -``` - -Use the atomic claim+transition verbs — `work_start` / `work_start_next` -(MCP) or `start-work` / `start-next-work` (CLI). Do **not** chain -`work_claim` (MCP) or `filigree claim` (CLI) with a subsequent status -update — the two-step form races against other agents; the combined verb is -atomic. - -**Ready ≠ startable.** The working status is type-specific (tasks → -`in_progress`, features → `building`). Bugs start at `triage`, which has no -single-hop transition into work (`triage → confirmed → fixing`), so a triage -bug is *ready* but not directly *startable*: `work_start` on one returns -`INVALID_TRANSITION` naming the next status, and `work_start_next` skips it. -`work_ready` items carry a `startable` flag (plus a `next_action` hint when -false). Pass `advance=true` (MCP) / `--advance` (CLI) to walk the soft -transitions to the nearest working status automatically. - -### Observations: when (and when not) to use them - -`observation_create` is a fire-and-forget scratchpad for *incidental* defects — things -you notice *outside the scope of your current task* (a code smell in a -neighbouring file, a stale TODO, a missing test for an edge case you happened -to spot). Notes expire after 14 days unless promoted. Include `file_path` and -`line` when relevant. At session end, skim `observation_list` and either -`observation_dismiss` or `observation_promote` for what has accumulated. - -**You fix bugs in your currently defined scope. You do NOT use observations -to finish work prematurely.** If a defect, gap, or follow-up belongs to your -current task, you own it — handle it as part of that task: fix it now, expand -the task's scope, file a proper issue with a dependency, or surface it to the -user. Filing it as an observation and closing the task is *not* completing -the task; it is shipping known-broken work and hiding the debt in a 14-day -expiring scratchpad. The test is "would I have noticed this even if I weren't -working on this task?" If no, it's task scope, not an observation. - -### Priority scale - -- P0: Critical (drop everything) -- P1: High (do next) -- P2: Medium (default) -- P3: Low -- P4: Backlog - -### Reaching for tools - -MCP tool schemas describe each tool; `filigree --help` and `filigree ---help` are the authoritative CLI reference. You do not need to memorise -either catalogue. The verbs you will reach for most: - -- **Find work:** `work_ready`, `work_blocked`, `issue_list`, `issue_search` -- **Claim work:** `work_start`, `work_start_next` -- **Update:** `comment_add`, `label_add`, `issue_update`, `issue_close` -- **Admin (irreversible):** `issue_delete` (MCP) / `delete-issue` (CLI) — - hard-deletes a terminal issue and its rows; `admin_undo_last` cannot reverse it. -- **Scratchpad:** `observation_create`, `observation_list`, `observation_promote`, `observation_dismiss` -- **Cross-product entity bindings (ADR-029):** `entity_association_add`, - `entity_association_remove`, `entity_association_list`, - `entity_association_list_by_entity`. Used when a sibling tool (e.g. - Clarion) needs to bind a Filigree issue to a function, class, or - module identifier it owns. The `entity_id` is an opaque string - from Filigree's perspective; the consumer (the sibling tool's read - path) does drift detection against the stored - `content_hash_at_attach`. `entity_association_list_by_entity` is the - reverse-lookup surface — given a Clarion entity ID, return every - Filigree issue bound to it (project isolation is by DB file). Also - reachable over HTTP as - `GET/POST /api/issue/{issue_id}/entity-associations`, - `DELETE /api/issue/{issue_id}/entity-associations?entity_id=…`, - and `GET /api/entity-associations?entity_id=…`. -- **Health:** `stats_get`, `metrics_get`, `mcp_status_get` - -Pass `--actor ` (CLI) so events attribute to your agent identity. It -works in either position — before the verb (`filigree --actor X update …`) or -after it (`filigree update … --actor X`); the post-verb value overrides the -group-level one. - -### Error handling - -Errors return `{error: str, code: ErrorCode, details?: dict}`. Switch on -`code`, not on message text. Codes: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, -`INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`, -`INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, -`CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC`, -`BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. - -On `INVALID_TRANSITION`, call `workflow_transition_list` (MCP) or -`filigree transitions ` to see what the workflow allows from here. - -Two failure modes deserve a specific response: - -- **`SCHEMA_MISMATCH`** — the installed `filigree` is older than the project - database. The error message contains upgrade guidance. Surface it to the - user; do not retry. -- **`ForeignDatabaseError`** — filigree found a parent project's database - but no local `.filigree.conf`. Run `filigree init` in the current - directory. Do **not** `cd` upward to a different project unless that was - the actual intent. - diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f91fde..39be0475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,593 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - Unreleased + +3.0.0 is a **major release**. It opens a SemVer-major boundary to land the +deferred breaking wire-surface changes that could not ship mid-2.x without +breaking federation consumers (Clarion / Wardline / Shuttle): subsystem +namespacing of the ~115 MCP tool names (ADR-016), removal of the deprecated +`get_stats` alias keys, entity-association de-Clarionization, and the +`TransitionMode` enum. **Those breaking items are tracked as a checklist in the +release PR and land incrementally on this branch — the entries below are the +work already merged.** Consumers should not pin to 3.0.0 until the breaking +checklist is complete and a coordinated consumer-migration window is published. + +### Changed (BREAKING) + +- **Config-anchor cutover: `.filigree.conf` → `.weft/filigree/config.json` + (filigree-4bf16e64b6).** The project anchor moves off the legacy root file + `.filigree.conf` into the store's own `config.json` — completing the WEFT store + consolidation for config the way `migrate_store_to_weft` did for the database. + `filigree init` now **imports** a present `.filigree.conf` into + `.weft/filigree/config.json` (conf-wins on the fields the runtime served — + `prefix`/`enabled_packs`/`registry_backend`/`loomweave`/`project_name`; `mode` + stays config.json-authoritative; `db` is 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 operator `weft.toml [filigree].store_dir` override). `weft.toml` is never + written by filigree and never holds identity (the C-9c deletion test: filigree + boots with no `weft.toml`). Implicit agent-startup surfaces + (`generate_session_context`, the agent dashboard hook, stdio-MCP with no + `--project`) resolve via `find_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_dir` fallback, C-9f); only the conf **anchor** is demoted to + one-shot-import-and-retire. Existing installs migrate on their next `filigree + init`; nothing breaks until then. Because `config.json` is now the sole identity + authority (no conf backstop), a present-but-corrupt `config.json` is refused at + **open** with a structured `VALIDATION` error — 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 key + `clarion_entity_id`→`loomweave_entity_id`, the SEI prefix + `clarion:eid:`→`loomweave:eid:`, finding rule-ids `CLA-`→`LMWV-`, and the token + env var `CLARION_LOOM_TOKEN`→`WEFT_TOKEN`. No compatibility aliases. The v26 + migration rewrites every stored SEI prefix in place — the entity-association + column, the `deleted_issues` F5 tombstone `entity_ids` array, and the + entity-association audit events — plus finding rule-ids. Deployments must set + `WEFT_TOKEN` (the opaque federation bearer token; `CLARION_LOOM_TOKEN` is no + longer read). The `registry_backend` value/section is now `loomweave` (a + deployed `clarion` config 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 renamed `loomweave:eid:` entity_ids (Filigree never verifies them, so + reads do not break). The registry error codes (`CLARION_REGISTRY_VERSION_MISMATCH`, + `CLARION_OUT_OF_SYNC`) and the `loom://` URI scheme are intentionally NOT + renamed in 3.0.0 (the hub has not locked them; tracked as residuals). +- **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 `_` 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_tool` rejects a legacy name with the standard `NOT_FOUND` + (`Unknown tool`) envelope. MCP consumers that hardcode tool names must switch + to the new names — see [UPGRADING.md](docs/UPGRADING.md) for the full old→new + table. Callers that read `list_tools` dynamically 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_calls` in + `mcp_status_get` / `mcp-status`) is removed. +- **`TransitionMode` enum replaces the internal `backward: bool` (internal + Python API).** The transition-direction flag that was conflated with + force/escape semantics across `TemplateRegistry.validate_transition`, + `update_issue`, the `DBMixinProtocol` signature, and + `InvalidTransitionError` is now a `TransitionMode{FORWARD, BACKWARD}` enum + (`filigree.types.api.TransitionMode`); `InvalidTransitionError.backward` + becomes `.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 and `transition_forced` audit events are + unchanged. +- **`get_stats` alias keys `status_name_counts` / `status_category_counts` + removed.** Deprecated in 2.1.0 (filigree-17694d2db8) as exact duplicates of + `by_status` / `by_category`, they are now dropped from every surface that + carries `get_stats` output: the MCP `stats_get` tool, the MCP `summary_get` + JSON envelope (nested under `stats`), the HTTP `GET /api/stats` projection, + and the `filigree stats --json` CLI output. The keys never held information + beyond the canonical pair, so the drop loses no data. **Migration:** read + `by_status` (literal workflow status names) and `by_category` (template + categories `open`/`wip`/`done`). The public `GET /api/stats` endpoint 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). + +### Changed + +- **`safe_message` parity for claim/transition errors on HTTP & MCP + (filigree-d25e75cebf).** `ClaimConflictError` and `InvalidTransitionError` + now follow the `WrongProjectError` pattern: the untrusted HTTP/MCP error + *string* is a fixed, generic `safe_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 + keep `details.observed`/`details.expected` (the assignees); transition errors + keep `current_status`/`type_name`/`to_state` (and `valid_transitions` when + computed), now carried in the HTTP `details` payload and the MCP + `TransitionError` payload even when no allowed-transition hint was enriched + (previously these were only in the human string). The CLI keeps the full rich + `str(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 structured `details`; only a + consumer that string-matched the prose of these two error *messages* over + HTTP/MCP (rather than switching on `code` + reading `details`) is affected, + which the 2.0 envelope contract already directs against. Scope note: batch + per-item failures (`batch_close`/`batch_update`) and `AmbiguousTransitionError` + are intentionally out of scope — batch failures carry only structured + coordination data with no probe-sensitive text, and there is no + `WrongProjectError` batch precedent to mirror. + +### Fixed + +- **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 the `actor` is a self-asserted claim and the `verified_*` 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 an `actor_verification` posture (`verified`, `deferral`, explanatory `note`): on the dashboard/loom HTTP surface via the `/api/health` `auth` scope, and on the MCP surface via `mcp_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 to `filigree-81d3971467`; this change makes the *current* state honest, not silent. +- **Scan findings surface the wardline `suppression_state` at the top level.** A finding that wardline has baselined/waived/judged carried that verdict only inside `metadata.wardline.suppression_state`, so an agent triaging via the slim `finding_list` (or `finding_get`, or `GET /api/weft/findings`) could not tell an accepted/suppressed defect from open work without parsing nested metadata. `suppression_state` is now lifted onto `ScanFinding`/`ScanFindingWeft` (mirroring the N6 `issue_status` lift); `None` when 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) and `list-findings` (CLI) previously returned wardline-baselined/waived/judged findings mixed in with real ones at `status:open severity:high`, annotated but not hidden, so a `finding_list status=open severity=high` work-query still surfaced already-accepted defects. Both surfaces now default to `suppression=active`; pass `suppression=all` to include suppressed rows, or a specific verdict (`baselined`/`waived`/`judged`) to triage them. The default-hide lives at the agent surfaces only: the core `list_findings_global` primitive 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 explicit `suppression=` filter. `all` is now an advertised suppression-filter value. Complements the already-shipped `finding_promote` suppression guard (which refuses to mint a new issue from an accepted defect without `force`) 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), and `scan_status_get` returned run metadata (run id, pid, log path) with no sense of the file's findings — a vacuous run-state-only green. They now carry a `file_summary` (severity-bucketed `FindingsSummary`); 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. +- **`FindingsSummary` severity rollups now carry a `suppressed` breakdown so accepted defects are distinguishable from actionable work (filigree-c3e2b72f21).** The row level already separated open status from the wardline `suppression_state`, but every summary producer (`get_file_findings_summary`, `get_files_findings_summary`, `get_global_findings_stats`, and the inline `list_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 parallel `suppressed: SeverityBreakdown` is added, computed with the same shared classifier as the row-level `finding_list` suppression filter (so the two cannot drift). Consumers can now derive actionable work as `bucket − suppressed[bucket]`; no existing number changes. `GlobalFindingsStats` and `EnrichedFileItem.summary` inherit 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 in `filigree session-context` (CLI + MCP) so an + agent's orientation no longer silently reads "nothing to do" while + un-promoted findings sit in `scan_findings`. Honest-empty: omitted at 0; the + actionable/suppressed split uses the wardline `suppression_state`. +- **Aggregate/container types are never offered as startable work (F3).** + `release`, `epic`, `milestone`, and `phase` carry a declarative `container` + type-schema flag; `work_ready` reports them `startable=false` ("complete child + issues") and `start_next_work` skips 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 spurious `StoreMigrationBusyError` + when a daemon is live — `migrate_store_to_weft` short-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_weft` publishes the weft DB and copies + metadata (steps 1-2) before rewriting the conf's `db` field (step 3); a + present-but-unreadable conf made step 3's `read_conf` raise *after* those + mutations, escaping `filigree init`/`install` as an uncaught traceback and + leaving a half-published weft husk. The conf read is now a strict pre-mutation + gate (mirroring the `weft.toml` I1 read): a present-but-unreadable conf raises + `StoreMigrationConfUnreadableError` **before 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 clean `exit 1` with 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 with + `shutil.copytree` straight to the final path, guarded on `not 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_tree` copies into a dest-dir temp then `os.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_weft` now + holds `BEGIN IMMEDIATE` on 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 the `application_id` without a checkpoint that cannot run under + the fence). A writer already active at fence-acquire is refused with + `StoreMigrationBusyError` before any mutation (superseding the old copy-time + checkpoint-busy guard), and a short-/zero-`busy_timeout` writer gets a visible + `SQLITE_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.json` install reports its *actual* file mode, not an + assumed `0600`.** The success message unconditionally claimed `mode 0600` + while the `chmod(0o600)` that tightens the token-bearing file is best-effort — + on filesystems where `chmod` is 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 class `d2597d0` + set out to fix, partially reintroduced in the message path). The install now + stats the file after `chmod` and 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_file` honours its "unreadable → empty" contract for corrupt + files.** A non-UTF-8 federation-token file raised `UnicodeDecodeError` (a + `ValueError`, not `OSError`) — now caught (with a warning) so it fails closed + to "no token" instead of crashing server-mode auth / daemon boot / `doctor`. +- **`force` is now declared on the `promote_finding` / + `promote_finding_and_attach_entity` input 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 + `/mcp` is scoped) — and an **unscoped write fails closed with 400** instead of + a silent home-project write. Unscoped reads stay lenient. Every federation + response carries an `X-Filigree-Project` header 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_TOKEN` env pin), + no longer only against the daemon's home-store token — so a project presenting + its own token no longer 401s. Server-mode `filigree install` and `doctor + --fix` now embed the **project's** token in `.mcp.json` (not the home token); + existing installs re-heal via `filigree doctor --fix`, which also reports a + `.mcp.json` carrying 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. + +### Added + +- **`WEFT_FEDERATION_TOKEN` canonical inbound federation bearer + token + negotiation (filigree-0e4bc3d81a).** The bearer that gates Filigree's own + `/api/weft/*` + `/mcp` HTTP surface is now read from `WEFT_FEDERATION_TOKEN` + first, falling back to the **deprecated** `FILIGREE_FEDERATION_API_TOKEN` and + `FILIGREE_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 token `WEFT_TOKEN`; it is federation/deconfliction + plumbing, not a security secret. Server-mode `filigree install` now writes the + `.mcp.json` Authorization header as `Bearer ${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 + `.filigree/federation_token` and prints the `export` the operator must run + (filigree cannot write the agent's process env). `filigree doctor` now fails + the Claude Code MCP check when a streamable-http Authorization header + references an env var that does not resolve — turning the previously silent + `/mcp` 401 (an agent coordinating blind) into a diagnosable connectivity + check — and `doctor --fix` rewrites 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 verb `filigree reconciliation-debt` + (`--limit`/`--offset`/`--json`), and the MCP tool `reconciliation_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}`) and `signoff_seq`. Filigree + stores both verbatim and echoes them back on every read (HTTP + MCP + `entity_association_list` / `_list_by_entity`) — it has no key and **never** + verifies the signature, exactly as it treats `content_hash_at_attach`. Both + columns are nullable: Legis omits them when no key is configured, and + pre-v25 / non-governed bindings read `NULL`. 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`/`import` round-trips the new + columns. Wrong-typed `signature`/`signoff_seq` (incl. a `bool` for the + sequence) are rejected `400 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, MCP `close_issue` / + `batch_close`, and the CLI `close` command) via a shared transport-neutral + policy, so no surface is a bypass; the data layer makes no network calls. + Governance is **off** until `LEGIS_URL` is 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 as `502`. Batch closes + report a blocked issue per-item without aborting the batch. New env: + `LEGIS_URL`, optional `LEGIS_API_TOKEN`. + +- **Transport-bound actor identity (ADR-012, schema v24).** Every runtime write + now records a `verified_*` column alongside the claimed `actor`/`author`, + holding the OS-user identity the process verifiably ran as (or `NULL` when no + transport proof exists — all historical rows, unverified surfaces, and + system-authored writes). Resolved at the CLI and MCP-stdio entry points. A + non-blocking `ACTOR_MISMATCH` warning surfaces when the claimed and verified + identities disagree (CLI: stderr; MCP: response-envelope `warnings` array); + framework default actors (`cli`/`mcp`) are suppressed. No backfill; the + `events` dedup index is unchanged; `export`/`import` round-trips the new + columns. MCP-HTTP peer identity and dashboard auth remain deferred. + +- **`scanned_paths` on `POST /api/loom/scan-results` (and the classic/living + aliases) — close-on-fixed now fires from scan ingest.** A scanner can now send + `scanned_paths`: the authoritative set of files it visited this run, including + clean files with zero findings. When `mark_unseen` is true, the + absent-fingerprint sweep is driven off the union of files-with-findings and + `scanned_paths`, so a file whose **last/only** finding was fixed (and is + therefore absent from `findings`) is still reconciled to `unseen_in_latest` and + its linked issue **cascade-closes** — eagerly, from ingest, no longer only via + the age-gated `clean-stale` sweep. With `scanned_paths` non-empty, a fully-clean + scan (`findings: []`, `mark_unseen: true`) is now valid instead of `400`. + Optional and wire-compatible: a body omitting `scanned_paths` behaves exactly as + before. Unknown clean paths (no prior file record) are skipped, never created. + Wardline already emits this field; Filigree previously dropped it silently. + +### Fixed + +- **Instruction-file write hardening against 0-byte data loss (filigree-04bad2a2bf).** + Two defensive gaps closed around the CLAUDE.md/AGENTS.md/`.gitignore` write 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 + exclusive `portalocker` flock at `.filigree/instructions.lock` (mirroring + `ephemeral.lock`/`server.lock`), so two concurrent SessionStart hooks — or a + hook racing a manual `filigree 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/.gitignore` now lists `instructions.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 fresh `filigree init` creates 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 via `resolve_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.lock` already resolved correctly; `server.lock` is + 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 via + `shutil.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 atomic `os.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_weft` created + `.weft/filigree/` (`weft_store.mkdir`) *before* the busy-abortable checkpoint, + so a live writer holding the legacy DB raised `StoreMigrationBusyError` and + left an **empty** `.weft/filigree/` husk behind. `resolve_store_dir` then + declared that husk canonical purely on `is_dir()`, so the next confless open + (no `.filigree.conf` to 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 while `filigree init` migrates). Conf installs were unaffected (the conf + still pinned legacy). Fixed at two layers: `resolve_store_dir` now 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 eager + `weft_store.mkdir` is deferred until after the busy check passes, so a busy + abort leaves no husk at all. `find_filigree_anchor` inherits the fix for free + (it derives `store_dir` from `resolve_store_dir`). Distinct from the atomic-copy + fix above. + +- **`doctor --fix` repairs instruction files and `context.md` again + (filigree-f57cb498d4).** `--fix` now wires `CLAUDE.md`, `AGENTS.md`, and the + generated `context.md` back into its fixable set: instruction files via the + non-destructive marked-block injection (which preserves surrounding user + content), and `context.md` regenerated from the DB. This **partially reverses** + the `doctor --fix` narrowing in `54cdd65` for these filigree-owned/-managed + artifacts; `.gitignore` is *intentionally* left excluded (the user runs + `filigree install --gitignore` for that). `context.md` opens 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 `signature` was 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 to `NULL`, flipping a + previously-governed issue ungoverned so the gate skipped Legis entirely. Fix: + the data layer normalises blank signatures to `NULL` and the gate classifies + governed-ness by `is not None`; the re-attach UPSERT is now **sticky** — + `signature`/`signoff_seq`/`signed_content_hash` change 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 nullable `signed_content_hash` column + records the content the signature was cut over (the HMAC binds `content_hash`); + when it diverges from `content_hash_at_attach` the sign-off has **drifted** and + the gate fails closed with the new `STALE` verdict (a `409`, no network call) + until Legis re-signs over the new content — distinct from `UNAVAILABLE` so a + single drifted issue does not short-circuit a finding-cascade batch. Migration + backfills `signed_content_hash = content_hash_at_attach WHERE signature IS NOT + NULL`; `export`/`import` round-trips it. Detects *content* drift, not *identity* + drift (the v26 rebrand's `entity_id` rewrite 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_findings` batch + proved Legis unreachable, the short-circuit handed every remaining candidate a + synthetic `UNAVAILABLE` *without* 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 into `evaluate_closure_gate` (`legis_known_down`) and applied only + at the point a network call would happen — after the ungoverned/governance-off/ + `STALE` short-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 as `UNAVAILABLE`. + +- **`filigree init`/`install` now 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 naive `git add -A` in the window between `init` and `install` — + 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 and `instance_id`, and the generated `context.md`. + The shipped nested ignore excludes all of those. `filigree.db` and `config.json` are + 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 + by `install`) 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-`close` write path.** The + gate (B5) was enforced per transport verb — only `close_issue`/`batch_close` — + but `close_issue` routes through `update_issue`, so a governed issue could be + driven into a done-category status, ungated, via `update_issue`/`batch_update` + on 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_latest` while leaving the linked issue open + (the close helper had a single caller — the `clean-stale` sweep — 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 the `done`-category guard, surfacing failures in `warnings` and + 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=...)`; see `get_issue_findings`). The close cascade now skips the + close when any *other* linked finding is still open (a non-terminal, + non-`unseen_in_latest` status), 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-gated `clean-stale` sweep 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 fabricated `active`), + per ADR-017's two-axis model. `add_entity_association` now declares + `_skip_begin` on both the protocol stub and its `@_in_immediate_tx` + implementation so the stub-signature contract test and the mypy override check + agree. + +- **`doctor --fix` now clears the stale server-registry entries it already + flags.** In server mode, `doctor` reports every registered project whose + directory has vanished (`Directory gone: …`) with a `filigree server + unregister` hint, but `--fix` skipped them: those checks carry a dynamic, + non-unique name (`Project ""`) that the name-keyed fixer table never + matched, so each reported "manual intervention" despite a deterministic + remediation. `--fix` now routes them by a stable `code` + (`server_registry_orphan`) and removes them by their exact stored config key + in one locked pass (new `server.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 the + `ACTOR_MISMATCH` warning, inject it into the response envelope's `warnings[]` — + was wrapped in `except 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_warnings` bug, `_verified_actor` attribute 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. + +### Changed + +- **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-label`s 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. ### Security -- **Dashboard `/mcp` HTTP transport now requires a federation bearer token.** - The streamable-HTTP MCP endpoint exposes high-privilege agent tools. It was +- **Legis closure gate fails closed on contract-violating 2xx (B7, PR #52).** + `legis_client.check_closure_gate` previously treated *any* HTTP 2xx as + `ALLOWED`, reading only `reason`/`evidence` and never `allowed`. A `200 + {"allowed": false}`, a `200` with an empty/unparseable body (`_read_json` + yields `{}`), 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 requires `body["allowed"] is True` (the JSON `true` + literal — no truthiness coercion); anything else degrades to `UNREACHABLE`, + 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_gate` sent the `LEGIS_API_TOKEN` bearer on a + plain `urllib` request whose default redirect handler copies request headers + (minus content-length/content-type) onto the redirect target with no + same-origin check — so a `302` from 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 drops `Authorization` before + following a redirect (benign redirects are still followed; normal token auth + on a non-redirecting call is unchanged), and a non-`http(s)` `LEGIS_URL` is + 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 `.git` pointer alone. A spoofed `.git` file + (shipped in an untrusted clone, aimed at a victim project's + `worktrees/` admin dir) or a stale pointer (admin dir renamed, worktree + removed but the `.git` file left behind) could silently latch discovery onto + the wrong project's database. Discovery now verifies that the admin dir's + `gitdir` back-pointer resolves back to *this* `.git` file before redirecting; + on mismatch or read failure the `.git` entry stands as a project boundary. + +- **Dashboard `/mcp` HTTP 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 when `FILIGREE_FEDERATION_API_TOKEN` (or legacy `FILIGREE_API_TOKEN`) is set; otherwise `/mcp` returns `404`. Bearer enforcement on the mounted endpoint is unchanged (ADR-018; the loopback - boundary remains ADR-012). - - **Migration:** if you use **server-mode** MCP (`.mcp.json` with `type: - streamable-http` pointing at the daemon), set `FILIGREE_FEDERATION_API_TOKEN` - before starting the dashboard, or the MCP client will receive `404`. The - installer (`filigree doctor`/`init`) now warns when it writes a server-mode - config while no token is configured. Ethereal (stdio) MCP is unaffected. + boundary remains ADR-012). **Migration:** if you use **server-mode** MCP + (`.mcp.json` with `type: streamable-http` pointing at the daemon), set + `FILIGREE_FEDERATION_API_TOKEN` before starting the dashboard or the client + receives `404`; the installer (`filigree doctor`/`init`) now warns when it + writes a server-mode config while no token is configured. Ethereal (stdio) MCP + is unaffected. + +- **Installer / `doctor --fix` writes reject symlinked targets (#54).** The + maintenance write paths (`CLAUDE.md`/`AGENTS.md`, `.gitignore`, `.mcp.json`, + `.claude/settings.json`, skill directories, Codex `config.toml`, and their + `.bak` backups) 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-bearing `briefing_blocked` outcome. + +- **Hook module fallback uses Python safe-path mode (#57).** When command + resolution falls back to `python -m filigree`, it now emits `python -P -m + filigree`, preventing an attacker-controlled project-local `filigree/` 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.exe` + by absolute path instead of a bare `wmic`, closing a Windows executable + search-path / current-directory hijack when filigree runs from an untrusted + project directory. + +### Dependencies + +- **Bumped `starlette` 0.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 @@ -3351,22 +3921,22 @@ identified through systematic static analysis and verified against HEAD. - Issue validation against workflow templates (`validate`) - PEP 561 `py.typed` marker for downstream type checking -[Unreleased]: https://github.com/tachyon-beep/filigree/compare/v2.1.0...HEAD -[2.1.0]: https://github.com/tachyon-beep/filigree/compare/v2.0.3...v2.1.0 -[2.0.3]: https://github.com/tachyon-beep/filigree/compare/v2.0.2...v2.0.3 -[2.0.2]: https://github.com/tachyon-beep/filigree/compare/v2.0.1...v2.0.2 -[2.0.1]: https://github.com/tachyon-beep/filigree/compare/v2.0.0...v2.0.1 -[2.0.0]: https://github.com/tachyon-beep/filigree/compare/v1.6.1...v2.0.0 -[1.6.1]: https://github.com/tachyon-beep/filigree/compare/v1.6.0...v1.6.1 -[1.6.0]: https://github.com/tachyon-beep/filigree/compare/v1.5.2...v1.6.0 -[1.5.2]: https://github.com/tachyon-beep/filigree/compare/v1.5.1...v1.5.2 -[1.5.1]: https://github.com/tachyon-beep/filigree/compare/v1.5.0...v1.5.1 -[1.5.0]: https://github.com/tachyon-beep/filigree/compare/v1.4.1...v1.5.0 -[1.4.1]: https://github.com/tachyon-beep/filigree/compare/v1.4.0...v1.4.1 -[1.4.0]: https://github.com/tachyon-beep/filigree/compare/v1.3.0...v1.4.0 -[1.3.0]: https://github.com/tachyon-beep/filigree/compare/v1.2.0...v1.3.0 -[1.2.0]: https://github.com/tachyon-beep/filigree/compare/v1.1.1...v1.2.0 -[1.1.1]: https://github.com/tachyon-beep/filigree/compare/v1.1.0...v1.1.1 -[1.1.0]: https://github.com/tachyon-beep/filigree/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/tachyon-beep/filigree/compare/v0.1.0...v1.0.0 -[0.1.0]: https://github.com/tachyon-beep/filigree/releases/tag/v0.1.0 +[Unreleased]: https://github.com/foundryside-dev/filigree/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/foundryside-dev/filigree/compare/v2.0.3...v2.1.0 +[2.0.3]: https://github.com/foundryside-dev/filigree/compare/v2.0.2...v2.0.3 +[2.0.2]: https://github.com/foundryside-dev/filigree/compare/v2.0.1...v2.0.2 +[2.0.1]: https://github.com/foundryside-dev/filigree/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/foundryside-dev/filigree/compare/v1.6.1...v2.0.0 +[1.6.1]: https://github.com/foundryside-dev/filigree/compare/v1.6.0...v1.6.1 +[1.6.0]: https://github.com/foundryside-dev/filigree/compare/v1.5.2...v1.6.0 +[1.5.2]: https://github.com/foundryside-dev/filigree/compare/v1.5.1...v1.5.2 +[1.5.1]: https://github.com/foundryside-dev/filigree/compare/v1.5.0...v1.5.1 +[1.5.0]: https://github.com/foundryside-dev/filigree/compare/v1.4.1...v1.5.0 +[1.4.1]: https://github.com/foundryside-dev/filigree/compare/v1.4.0...v1.4.1 +[1.4.0]: https://github.com/foundryside-dev/filigree/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/foundryside-dev/filigree/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/foundryside-dev/filigree/compare/v1.1.1...v1.2.0 +[1.1.1]: https://github.com/foundryside-dev/filigree/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/foundryside-dev/filigree/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/foundryside-dev/filigree/compare/v0.1.0...v1.0.0 +[0.1.0]: https://github.com/foundryside-dev/filigree/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 18f97708..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,118 +0,0 @@ - -## Filigree Issue Tracker - -`filigree` tracks tasks for this project. Data lives in `.filigree/`. Prefer -the MCP tools (`mcp__filigree__*`) when available; fall back to the `filigree` -CLI otherwise. - -### Workflow - -```bash -# At session start -filigree session-context # ready / in-progress / critical path - -# Pick up the next startable issue (atomic claim + transition into its working status) -filigree start-next-work --assignee -# ...or claim a specific issue -filigree start-work --assignee - -# Do the work, commit, then -filigree close -``` - -Use the atomic claim+transition verbs — `work_start` / `work_start_next` -(MCP) or `start-work` / `start-next-work` (CLI). Do **not** chain -`work_claim` (MCP) or `filigree claim` (CLI) with a subsequent status -update — the two-step form races against other agents; the combined verb is -atomic. - -**Ready ≠ startable.** The working status is type-specific (tasks → -`in_progress`, features → `building`). Bugs start at `triage`, which has no -single-hop transition into work (`triage → confirmed → fixing`), so a triage -bug is *ready* but not directly *startable*: `work_start` on one returns -`INVALID_TRANSITION` naming the next status, and `work_start_next` skips it. -`work_ready` items carry a `startable` flag (plus a `next_action` hint when -false). Pass `advance=true` (MCP) / `--advance` (CLI) to walk the soft -transitions to the nearest working status automatically. - -### Observations: when (and when not) to use them - -`observation_create` is a fire-and-forget scratchpad for *incidental* defects — things -you notice *outside the scope of your current task* (a code smell in a -neighbouring file, a stale TODO, a missing test for an edge case you happened -to spot). Notes expire after 14 days unless promoted. Include `file_path` and -`line` when relevant. At session end, skim `observation_list` and either -`observation_dismiss` or `observation_promote` for what has accumulated. - -**You fix bugs in your currently defined scope. You do NOT use observations -to finish work prematurely.** If a defect, gap, or follow-up belongs to your -current task, you own it — handle it as part of that task: fix it now, expand -the task's scope, file a proper issue with a dependency, or surface it to the -user. Filing it as an observation and closing the task is *not* completing -the task; it is shipping known-broken work and hiding the debt in a 14-day -expiring scratchpad. The test is "would I have noticed this even if I weren't -working on this task?" If no, it's task scope, not an observation. - -### Priority scale - -- P0: Critical (drop everything) -- P1: High (do next) -- P2: Medium (default) -- P3: Low -- P4: Backlog - -### Reaching for tools - -MCP tool schemas describe each tool; `filigree --help` and `filigree ---help` are the authoritative CLI reference. You do not need to memorise -either catalogue. The verbs you will reach for most: - -- **Find work:** `work_ready`, `work_blocked`, `issue_list`, `issue_search` -- **Claim work:** `work_start`, `work_start_next` -- **Update:** `comment_add`, `label_add`, `issue_update`, `issue_close` -- **Admin (irreversible):** `issue_delete` (MCP) / `delete-issue` (CLI) — - hard-deletes a terminal issue and its rows; `admin_undo_last` cannot reverse it. -- **Scratchpad:** `observation_create`, `observation_list`, `observation_promote`, `observation_dismiss` -- **Cross-product entity bindings (ADR-029):** `entity_association_add`, - `entity_association_remove`, `entity_association_list`, - `entity_association_list_by_entity`. Used when a sibling tool (e.g. - Clarion) needs to bind a Filigree issue to a function, class, or - module identifier it owns. The `entity_id` is an opaque string - from Filigree's perspective; the consumer (the sibling tool's read - path) does drift detection against the stored - `content_hash_at_attach`. `entity_association_list_by_entity` is the - reverse-lookup surface — given a Clarion entity ID, return every - Filigree issue bound to it (project isolation is by DB file). Also - reachable over HTTP as - `GET/POST /api/issue/{issue_id}/entity-associations`, - `DELETE /api/issue/{issue_id}/entity-associations?entity_id=…`, - and `GET /api/entity-associations?entity_id=…`. -- **Health:** `stats_get`, `metrics_get`, `mcp_status_get` - -Pass `--actor ` (CLI) so events attribute to your agent identity. It -works in either position — before the verb (`filigree --actor X update …`) or -after it (`filigree update … --actor X`); the post-verb value overrides the -group-level one. - -### Error handling - -Errors return `{error: str, code: ErrorCode, details?: dict}`. Switch on -`code`, not on message text. Codes: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, -`INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`, -`INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, -`CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC`, -`BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. - -On `INVALID_TRANSITION`, call `workflow_transition_list` (MCP) or -`filigree transitions ` to see what the workflow allows from here. - -Two failure modes deserve a specific response: - -- **`SCHEMA_MISMATCH`** — the installed `filigree` is older than the project - database. The error message contains upgrade guidance. Surface it to the - user; do not retry. -- **`ForeignDatabaseError`** — filigree found a parent project's database - but no local `.filigree.conf`. Run `filigree init` in the current - directory. Do **not** `cd` upward to a different project unless that was - the actual intent. - diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ba8dcbdd..1ff75b72 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via [GitHub Security Advisories](https://github.com/tachyon-beep/filigree/security/advisories/new). All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via [GitHub Security Advisories](https://github.com/foundryside-dev/filigree/security/advisories/new). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcd4ce43..4f622b67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for considering a contribution to filigree. Whether it's a bug report, ## How to Report Bugs -Open a [bug report](https://github.com/tachyon-beep/filigree/issues/new?template=bug_report.yml) on GitHub. Include: +Open a [bug report](https://github.com/foundryside-dev/filigree/issues/new?template=bug_report.yml) on GitHub. Include: - Filigree version (`filigree --version`) - Whether you hit the issue via CLI, MCP, or the dashboard @@ -14,12 +14,12 @@ Open a [bug report](https://github.com/tachyon-beep/filigree/issues/new?template ## How to Suggest Features -Open a [feature request](https://github.com/tachyon-beep/filigree/issues/new?template=feature_request.yml). Describe the problem you're solving and your proposed approach. +Open a [feature request](https://github.com/foundryside-dev/filigree/issues/new?template=feature_request.yml). Describe the problem you're solving and your proposed approach. ## Development Setup ```bash -git clone https://github.com/tachyon-beep/filigree.git +git clone https://github.com/foundryside-dev/filigree.git cd filigree uv sync --group dev ``` @@ -87,7 +87,7 @@ Keep PRs focused. One logical change per PR is easier to review than a large omn ## First-Time Contributors -Look for issues labeled [`good first issue`](https://github.com/tachyon-beep/filigree/labels/good%20first%20issue). Good starting points include: +Look for issues labeled [`good first issue`](https://github.com/foundryside-dev/filigree/labels/good%20first%20issue). Good starting points include: - Documentation improvements - Adding tests for uncovered code paths diff --git a/README.md b/README.md index 2869569b..ef43aed6 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,28 @@ Local-first issue tracker designed for AI coding agents — SQLite, MCP tools, no cloud, no accounts. -[![CI](https://github.com/tachyon-beep/filigree/actions/workflows/ci.yml/badge.svg)](https://github.com/tachyon-beep/filigree/actions/workflows/ci.yml) +[![CI](https://github.com/foundryside-dev/filigree/actions/workflows/ci.yml/badge.svg)](https://github.com/foundryside-dev/filigree/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/filigree)](https://pypi.org/project/filigree/) [![Python 3.11+](https://img.shields.io/pypi/pyversions/filigree)](https://pypi.org/project/filigree/) -[![License: MIT](https://img.shields.io/pypi/l/filigree)](https://github.com/tachyon-beep/filigree/blob/main/LICENSE) +[![License: MIT](https://img.shields.io/pypi/l/filigree)](https://github.com/foundryside-dev/filigree/blob/main/LICENSE) ## What Is Filigree? -Filigree is a lightweight, SQLite-backed issue tracker designed for AI coding agents (Claude Code, Codex, etc.) to use as first-class citizens. It exposes 114 MCP tools so agents interact natively, plus a full CLI for humans and background subagents. +Filigree is a lightweight, SQLite-backed issue tracker designed for AI coding agents (Claude Code, Codex, etc.) to use as first-class citizens. It exposes 116 MCP tools so agents interact natively, plus a full CLI for humans and background subagents. Traditional issue trackers are human-first — agents scrape CLI output or parse API responses. Filigree flips this: agents get a pre-computed `context.md` at session start, claim work with optimistic locking, and resume sessions via event streams without re-reading history. For Claude Code, `filigree install` wires up session hooks and a workflow skill pack so agents get project context automatically. -Filigree is local-first. No cloud, no accounts. Each project gets a `.filigree/` directory (like `.git/`) containing a SQLite database, configuration, and auto-generated context summary. Installations support two modes: `ethereal` (default, per-project) and `server` (persistent multi-project daemon). Filigree 2.0 also adds a named Loom HTTP generation at `/api/loom/*` for federation-aware integrations while keeping the classic HTTP surface supported for existing callers. +Filigree is local-first. No cloud, no accounts. Each project gets a `.filigree/` directory (like `.git/`) containing a SQLite database, configuration, and auto-generated context summary. Installations support two modes: `ethereal` (default, per-project) and `server` (persistent multi-project daemon). Filigree 2.0 also adds a named Weft HTTP generation at `/api/weft/*` for federation-aware integrations while keeping the classic HTTP surface supported for existing callers. + +Filigree is the work-state member of the **Weft federation** — a family of independently-useful code-governance tools woven together by narrow, additive contracts. Filigree runs fully standalone; the Weft surface is optional enrichment. The authoritative federation roster, axiom, and composition doctrine live at the Weft hub (`~/loom`, see `~/loom/doctrine.md`); Filigree owns its own HTTP/MCP/CLI surface and contracts (see [docs/federation/contracts.md](docs/federation/contracts.md)). **Security boundary:** Filigree does not encrypt, sandbox, harden, or secure stored project data beyond ordinary filesystem permissions and standard HTTPS transport if you put it behind HTTPS yourself. Do not use Filigree for secure, regulated, confidential, or business-sensitive data. ### Key Features -- **MCP server** with 114 tools — agents interact natively without parsing text +- **MCP server** with 116 tools — agents interact natively without parsing text - **Full CLI** with `--json` output for background subagents and `--actor` for audit trails -- **Loom HTTP generation** — stable `/api/loom/*` contracts with classic compatibility for existing integrations +- **Weft HTTP generation** — stable `/api/weft/*` contracts with classic compatibility for existing integrations - **Claude Code integration** — session hooks inject project snapshots at startup; bundled skill pack teaches agents workflow patterns - **Workflow templates** — 24 issue types across 9 packs with enforced state machines - **Dependency graph** — blockers, ready-queue, critical path analysis @@ -47,13 +49,13 @@ flowchart TD Human["You + scripts"] Browser["Browser"] - MCP["MCP server
(114 namespaced tools, stdio)"] + MCP["MCP server
(115 namespaced tools, stdio)"] CLI["filigree CLI
(--json, --actor)"] Dash["Web dashboard
(localhost:8377)"] DB[("SQLite database
.filigree/filigree.db")] Ctx["context.md
(pre-computed orientation)"] - Loom["Loom HTTP /api/loom/*
(optional federation)"] + Weft["Weft HTTP /api/weft/*
(optional federation)"] Agent -->|"calls tools over stdio"| MCP Human -->|"runs commands"| CLI @@ -66,7 +68,7 @@ flowchart TD DB -->|"regenerates on every mutation"| Ctx Ctx -->|"injected at session start"| Agent - DB -.->|"serves federation peers"| Loom + DB -.->|"serves federation peers"| Weft ``` **The work loop** is the same whether you drive it from the CLI or an agent @@ -141,7 +143,7 @@ pip install filigree # CLI + MCP server + Web dashboard Or from source: ```bash -git clone https://github.com/tachyon-beep/filigree.git +git clone https://github.com/foundryside-dev/filigree.git cd filigree && uv sync ``` @@ -169,14 +171,14 @@ The session hook runs `filigree session-context` at startup, giving the agent a ### Dashboard Authentication Scope -The dashboard is local-first and assumes loopback/local filesystem trust by default. Setting `FILIGREE_FEDERATION_API_TOKEN` enables bearer-token authentication only for federation and agent-ingest surfaces; the older `FILIGREE_API_TOKEN` is still accepted as a backward-compatible fallback. The token value is never reported by `/api/health`, but the health payload does report which auth scope is enabled. +The dashboard is local-first and assumes loopback/local filesystem trust by default. Setting `WEFT_FEDERATION_TOKEN` enables bearer-token authentication only for federation and agent-ingest surfaces; the older `FILIGREE_FEDERATION_API_TOKEN` and `FILIGREE_API_TOKEN` names are still accepted as deprecated, backward-compatible fallbacks (removal scheduled post-1.0). This token is federation/deconfliction plumbing, not a security secret. The token value is never reported by `/api/health`, but the health payload does report which auth scope is enabled. | Route class | Authentication | |-------------|----------------| | Dashboard UI (`/`) | Open under the local loopback trust boundary | | Classic dashboard API (`/api/issues`, `/api/issue/{id}`, `/api/health`) | Open under the local loopback trust boundary | -| Federation and scanner ingest (`/api/loom/*`, `/api/scan-results`, `/api/observations`, `/api/v1/scan-results`) | Bearer token when `FILIGREE_FEDERATION_API_TOKEN` or fallback `FILIGREE_API_TOKEN` is set | -| MCP HTTP endpoint (`/mcp`, `/mcp/*`) | Bearer token when `FILIGREE_FEDERATION_API_TOKEN` or fallback `FILIGREE_API_TOKEN` is set | +| Federation and scanner ingest (`/api/weft/*`, `/api/scan-results`, `/api/observations`, `/api/v1/scan-results`) | Bearer token when `WEFT_FEDERATION_TOKEN` (or a deprecated `FILIGREE_*_API_TOKEN` alias) is set | +| MCP HTTP endpoint (`/mcp`, `/mcp/*`) | Bearer token when `WEFT_FEDERATION_TOKEN` (or a deprecated `FILIGREE_*_API_TOKEN` alias) is set | ## Why Filigree? @@ -225,8 +227,8 @@ Nothing in Filigree is encrypted or secured beyond ordinary local filesystem pro |----------|-------------| | [Getting Started](docs/getting-started.md) | 5-minute tutorial: install, init, first issue | | [CLI Reference](docs/cli.md) | All CLI commands with full parameter docs | -| [MCP Server Reference](docs/mcp.md) | 114 MCP tools for agent-native interaction | -| [Federation Contracts](docs/federation/contracts.md) | Classic and Loom HTTP generation contracts | +| [MCP Server Reference](docs/mcp.md) | 116 MCP tools for agent-native interaction | +| [Federation Contracts](docs/federation/contracts.md) | Classic and Weft HTTP generation contracts | | [Workflow Templates](docs/workflows.md) | State machines, packs, field schemas, enforcement | | [Agent Integration](docs/agent-integration.md) | Multi-agent patterns, claiming, session resumption | | [Python API Reference](docs/api-reference.md) | FiligreeDB, Issue, TemplateRegistry for programmatic use | @@ -252,7 +254,7 @@ Node-backed static dashboard pytest tests and dashboard JavaScript quality gates. ```bash -git clone https://github.com/tachyon-beep/filigree.git +git clone https://github.com/foundryside-dev/filigree.git cd filigree uv sync --group dev diff --git a/READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md b/READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md deleted file mode 100644 index cec2fa26..00000000 --- a/READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md +++ /dev/null @@ -1,429 +0,0 @@ -# Filigree Read-Only Codebase Audit - 2026-06-04 - -Scope: `/home/john/filigree` - -This is a synthesis of five specialized read-only audits over the current tree: - -- Architecture Critic: package boundaries, coupling, cohesion, design fragility. -- Systems Thinker: propagation paths, feedback loops, hidden side effects, failure modes. -- Python Engineer: Python implementation details, typing surfaces, exception handling, parser/scanner idioms. -- Quality Engineer: tests, CI structure, coverage gates, maintainability. -- Security Architect: trust boundaries, untrusted input, auth, parsing, MCP, Clarion, scanner/LLM-facing flows. - -The request listed five roles and later referred to seven agents; this audit used the five listed roles. Each subagent prompt specified `enable_write_tools=false` and `enable_mcp_tools=false`, forbade write tools and MCP tools, and included the requested instruction not to use escaped double quotes in tool arguments. The audit did not run test, build, format, or migration commands; it used source inspection only. The only write performed for this task is this report artifact. - -## Severity Summary - -Critical: none confirmed. - -High: - -- F-001: Clarion bearer tokens can be sent to non-loopback origins when code bypasses or skips the capability probe. -- F-002: The living `/api/observations` alias is mounted but omitted from the bearer-token auth predicate. - -Medium: - -- F-003: Bundled scanner helpers cannot authenticate to the gated scanner callback endpoint. -- F-004: Dashboard inline JavaScript handlers mix JS-string escaping with HTML-attribute context. -- F-005: MCP schema validation ignores JSON Schema `required` properties. -- F-006: Concurrent dependency removal can record false undoable removal events. -- F-007: Scanner-created observations can detach from canonical file identity. -- F-008: HTTP observation listing mutates state by sweeping expired observations. -- F-009: The Python pytest job depends on Node but does not provision it. -- F-010: Live Clarion drift detection is manual-only in CI. - -Low: - -- F-011: Dashboard project-cache locking serializes potentially slow DB initialization. -- F-012: MCP tool modules import transport-server runtime globals. -- F-013: Scanner reporting paths have divergent CLI/MCP policy and normalization behavior. -- F-014: Coverage and XSS guardrails are present but brittle for the most security-sensitive surfaces. -- F-015: The opt-in bearer token protects only federation paths, which is easy to misread operationally. - -## Critical Findings - -No Critical findings were confirmed during this read-only audit. - -## High Findings - -### F-001 - Clarion bearer token origin guard is not enforced at construction or request time - -Locations: - -- [registry.py](/home/john/filigree/src/filigree/registry.py:359) lines 359-365: `_validate_clarion_token_origin()` rejects token-bearing requests to non-loopback origins. -- [registry.py](/home/john/filigree/src/filigree/registry.py:382) lines 382-397: `probe_clarion_capabilities()` calls the guard before the capability request. -- [registry.py](/home/john/filigree/src/filigree/registry.py:569) lines 569-594: `ClarionRegistry.__post_init__()` normalizes the base URL and creates an HTTP client, but does not call the guard. -- [registry.py](/home/john/filigree/src/filigree/registry.py:614) line 614, [registry.py](/home/john/filigree/src/filigree/registry.py:753) line 753, and [registry.py](/home/john/filigree/src/filigree/registry.py:931) line 931: request paths attach `_clarion_headers(auth_token=self.auth_token, ...)`. -- [registry.py](/home/john/filigree/src/filigree/registry.py:594) line 594: the persistent client follows redirects. - -Problem: - -The code has a clear security invariant: Clarion bearer tokens should only be sent to loopback Clarion origins by default. That invariant is enforced only by the capability probe path. Direct `ClarionRegistry` construction, future probe-skipping flows, and any request path reached after construction can still attach `Authorization: Bearer ...` to a normalized `http(s)` base URL that is not loopback. Because the persistent client follows redirects, a token-bearing request also needs redirect-origin handling. - -Impact: - -A token configured for local Clarion can be sent to an arbitrary configured remote origin, or potentially to a redirect target, if a code path constructs or uses `ClarionRegistry` without a successful guarded probe. This is a credential-exfiltration class defect. - -Remediation: - -1. Enforce `_validate_clarion_token_origin()` in `ClarionRegistry.__post_init__()` after `normalize_clarion_base_url()` and before creating the client. -2. Re-check the concrete request URL immediately before each request that attaches auth headers. -3. Disable redirects for token-bearing Clarion requests, or allow redirects only when every hop remains on an approved loopback origin. -4. Add regression tests for: - - direct `ClarionRegistry("https://example.invalid", auth_token="secret")`; - - a `FiligreeDB` construction path where the capability probe is skipped; - - redirect behavior from loopback to non-loopback. - -### F-002 - Living `/api/observations` alias bypasses bearer-token auth - -Locations: - -- [dashboard_auth.py](/home/john/filigree/src/filigree/dashboard_auth.py:28) lines 28-29: living/classic federation aliases include `scan-results` but not `observations`. -- [dashboard_auth.py](/home/john/filigree/src/filigree/dashboard_auth.py:32) lines 32-51: `is_loom_scoped_path()` gates only `/api/loom/*`, configured aliases, and `/mcp`. -- [dashboard_routes/analytics.py](/home/john/filigree/src/filigree/dashboard_routes/analytics.py:692) lines 692-695: `POST /api/loom/observations` is the protected loom observation write path. -- [dashboard_routes/analytics.py](/home/john/filigree/src/filigree/dashboard_routes/analytics.py:700) lines 700-723: `POST /api/observations` is mounted as the living equivalent. -- [dashboard.py](/home/john/filigree/src/filigree/dashboard.py:515) lines 515-516: both files and analytics living-surface routers are mounted. -- [tests/api/test_loom_auth.py](/home/john/filigree/tests/api/test_loom_auth.py:63) lines 63-77: the alias drift guard imports only `dashboard_routes.files.create_living_surface_router()`, so analytics aliases are not checked. - -Problem: - -When `FILIGREE_API_TOKEN` is configured, the token middleware gates `/api/loom/observations`, but the living alias `/api/observations` is not included in `LIVING_FEDERATION_ALIASES`. The application mounts the analytics living router, and its `POST /observations` handler delegates to the same observation-creation logic. - -Impact: - -A deployment that believes federation writes are protected by `FILIGREE_API_TOKEN` still exposes a write-capable living alias for observation ingestion without the token. This weakens the explicit auth boundary and lets unauthenticated callers add observation records on the living surface. - -Remediation: - -1. Add `observations` to the protected living federation aliases, or derive protected aliases from all mounted living routers rather than maintaining a hand-written set. -2. Extend the alias drift test to inspect both `files.create_living_surface_router()` and `analytics.create_living_surface_router()`. -3. Add integration tests proving unauthenticated `POST /api/observations` returns 401 when `FILIGREE_API_TOKEN` is set, and authenticated calls succeed. - -## Medium Findings - -### F-003 - Bundled scanner callbacks do not send bearer tokens to the gated scan-results endpoint - -Locations: - -- [bundled_scanners.py](/home/john/filigree/src/filigree/bundled_scanners.py:38) lines 38-80: bundled Codex and Claude scanners are launched with `--api-url`. -- [scan_utils.py](/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:346) lines 346-387: `post_to_api()` posts to `/api/scan-results` with only `Content-Type`. -- [dashboard_auth.py](/home/john/filigree/src/filigree/dashboard_auth.py:28) lines 28-51: `/api/scan-results` is one of the gated federation aliases. -- [tests/util/test_scan_utils.py](/home/john/filigree/tests/util/test_scan_utils.py:1118) lines 1118-1190: callback tests assert URL and content-type behavior, but do not cover auth headers. - -Problem: - -The scanner callback endpoint is intentionally gated when `FILIGREE_API_TOKEN` is configured, but the packaged scanner posting helper has no token parameter and sends no `Authorization` header. - -Impact: - -Secured deployments can silently break managed scanner ingestion: scans may run, then fail to report findings with 401 responses. That is a security/quality feedback-loop failure: the system becomes more secure at the HTTP boundary while losing scanner telemetry. - -Remediation: - -1. Add scanner-side token support, preferably explicit and testable: `--api-token-env`, `--api-token`, or a propagated environment convention documented next to `FILIGREE_API_TOKEN`. -2. Have `post_to_api()` attach `Authorization: Bearer ` when configured. -3. Update bundled scanner command templates to pass or expose the token source. -4. Add tests for both tokenless and token-bearing scanner callback requests. - -### F-004 - Inline dashboard handlers use JS-string escaping in HTML-attribute context - -Locations: - -- [ui.js](/home/john/filigree/src/filigree/static/js/ui.js:46) lines 46-64: `escHtml()` and `escJsSingle()` are separate context-specific helpers. -- [ui.js](/home/john/filigree/src/filigree/static/js/ui.js:163) lines 163-170: `issueIdChip()` inserts `escJsSingle(id)` into double-quoted inline event attributes. -- [detail.js](/home/john/filigree/src/filigree/static/js/views/detail.js:112) lines 112-119: dependency UI builds inline handlers with JS-escaped issue IDs. -- [kanban.js](/home/john/filigree/src/filigree/static/js/views/kanban.js:388) lines 388-418: cards mix `data-id="${escHtml(issue.id)}"` with inline `onclick="openDetail('${safeIssueId}')"`. -- [types/core.py](/home/john/filigree/src/filigree/types/core.py:20) lines 20-25: `make_issue_id()` enforces non-empty strings, but not an ID grammar. -- [core.py](/home/john/filigree/src/filigree/core.py:568) lines 568-571 and [core.py](/home/john/filigree/src/filigree/core.py:1494) line 1494: issue IDs are generated from a configurable prefix without a conservative character grammar. -- [db_meta.py](/home/john/filigree/src/filigree/db_meta.py:434) lines 434-441: imported IDs are prefix-checked but not normalized to a safe DOM/attribute grammar. - -Problem: - -`escJsSingle()` escapes single quotes, backslashes, line breaks, and angle brackets for a JavaScript single-quoted string. It does not escape every character relevant to a surrounding double-quoted HTML attribute. Several templates place `safeId` directly inside inline `onclick`/`onkeydown` attributes. If an issue ID or configured/imported prefix can contain a double quote or other attribute-breaking character, the handler attribute can be malformed and potentially injectable. - -Impact: - -This is a stored local-dashboard XSS risk reachable through issue IDs or imported/project-prefix-controlled IDs. The dashboard is local-first, but this still crosses a trust boundary because project data and imported artifacts can be untrusted. - -Remediation: - -1. Prefer removing inline event handlers. Render inert DOM with escaped text/data attributes, then bind listeners with JavaScript. -2. If inline handlers remain, compose escaping for both contexts: HTML-attribute escaping outside plus JS-string escaping inside. -3. Add a conservative issue ID and prefix grammar for generated/imported IDs. -4. Add DOM/runtime tests with IDs containing `"`, `'`, `<`, `>`, `&`, and whitespace. - -### F-005 - MCP schema validation ignores JSON Schema `required` properties - -Locations: - -- [mcp_server.py](/home/john/filigree/src/filigree/mcp_server.py:537) lines 537-592: `_validate_schema_value()` checks type, bounds, properties, and `additionalProperties`, but does not enforce `required`. -- [mcp_server.py](/home/john/filigree/src/filigree/mcp_server.py:595) lines 595-609: `_schema_validation_error()` delegates to the incomplete validator. -- [mcp_server.py](/home/john/filigree/src/filigree/mcp_server.py:905) lines 905-926: handler dispatch returns schema errors, otherwise lets handler exceptions propagate. -- [mcp_tools/scanners.py](/home/john/filigree/src/filigree/mcp_tools/scanners.py:437) lines 437-447: `preview_scan` declares required inputs in its schema. -- [mcp_tools/scanners.py](/home/john/filigree/src/filigree/mcp_tools/scanners.py:1481) lines 1481-1490: `_handle_preview_scan()` indexes `args["scanner"]` and `args["file_path"]`. -- [types/inputs.py](/home/john/filigree/src/filigree/types/inputs.py:755) lines 755-758: `PreviewScanArgs` is a required-field `TypedDict`. - -Problem: - -Schema metadata says fields are required, and handler code assumes they exist, but the generic MCP validator never checks the `required` array. Missing fields therefore pass schema validation and fail later as raw handler exceptions. - -Impact: - -Clients receive less precise errors, handlers can leak implementation exceptions, and MCP behavior diverges from the advertised tool contract. This also makes future handlers fragile if they rely on required-schema enforcement. - -Remediation: - -1. In `_validate_schema_value()`, when `value` is an object and `schema["required"]` is a list of strings, reject any missing required key before validating present properties. -2. Add tests for at least `preview_scan` with missing `scanner`, missing `file_path`, and both present. -3. Consider defensive `.get()` checks in handlers where missing values could cause confusing downstream errors. - -### F-006 - Concurrent dependency removal can record false undoable removal events - -Locations: - -- [db_planning.py](/home/john/filigree/src/filigree/db_planning.py:219) lines 219-224: `add_dependency()` begins an immediate transaction. -- [db_planning.py](/home/john/filigree/src/filigree/db_planning.py:285) lines 285-304: `remove_dependency()` reads, deletes, records an event, and commits without `_begin_immediate()`. -- [db_events.py](/home/john/filigree/src/filigree/db_events.py:443) lines 443-474: undo of `dependency_removed` re-inserts with `INSERT OR IGNORE`. - -Problem: - -`remove_dependency()` performs a read-then-delete-then-event sequence without taking the same early write lock used by `add_dependency()`. If two connections race, both can observe the dependency before either commit. One deletes the row; the other can still proceed to record a removal event after its delete affects zero rows. - -Impact: - -The event log can claim a dependency was removed by a command that did not actually remove it. Undoing that false event can restore a dependency edge that the second caller never owned. This corrupts the planning dependency history under concurrent use. - -Remediation: - -1. Call `_begin_immediate(self.conn, "remove_dependency")` before existence checks and the select/delete/event sequence. -2. Check `cursor.rowcount` after the delete and record `dependency_removed` only when exactly one row was deleted. -3. Add a two-connection concurrency regression test that races two removals and asserts only one event is recorded. - -### F-007 - Scanner-created observations can detach from canonical file identity - -Locations: - -- [db_files.py](/home/john/filigree/src/filigree/db_files.py:953) lines 953-1052: `_upsert_file_record()` stores the registry-normalized canonical path and file ID. -- [db_files.py](/home/john/filigree/src/filigree/db_files.py:1232) lines 1232-1255: scanner-created observations pass `file_path=path` rather than the canonical stored path or file ID. -- [db_observations.py](/home/john/filigree/src/filigree/db_observations.py:308) lines 308-318: observation creation links `file_id` by exact `file_records.path = file_path` lookup. - -Problem: - -File ingestion resolves and stores a canonical registry path. Observation creation then uses the original scanner path and relies on exact path lookup to attach a `file_id`. If registry canonicalization changes the path, the observation can be created without a `file_id` even though the finding has one. - -Impact: - -Scanner findings and their promoted observations can diverge in file identity. Filters, file-centric timelines, and downstream triage can miss related observations. - -Remediation: - -1. Pass the known canonical stored path or trusted `file_id` into `create_observation()` for scanner-created observations. -2. Prefer linking by `file_id` where the caller already has it, with `file_path` as display metadata. -3. Add a regression test using a registry backend that canonicalizes `./path.py` to `path.py` and asserts the observation links to the same file record as the finding. - -### F-008 - HTTP observation listing mutates state by sweeping expired observations - -Locations: - -- [db_observations.py](/home/john/filigree/src/filigree/db_observations.py:448) lines 448-466: `list_observations()` defaults `sweep=True`. -- [db_observations.py](/home/john/filigree/src/filigree/db_observations.py:536) line 536: listing can call `_sweep_expired_observations()`. -- [dashboard_routes/analytics.py](/home/john/filigree/src/filigree/dashboard_routes/analytics.py:665) lines 665-690: HTTP `GET /api/loom/observations` calls `db.list_observations(...)` without `sweep=False`. -- [mcp_tools/observations.py](/home/john/filigree/src/filigree/mcp_tools/observations.py:403) lines 403-424: MCP list observations explicitly passes `sweep=False`. - -Problem: - -The MCP surface treats list observations as read-only, but the HTTP loom list route can delete/transition expired observations as a side effect of a GET request. - -Impact: - -Read requests can mutate audit state, produce unexpected event churn, and contend with writers. It also creates behavior drift between MCP and HTTP list surfaces for the same conceptual operation. - -Remediation: - -1. Pass `sweep=False` in `api_loom_list_observations()`. -2. Move expiration sweeping to an explicit maintenance/write endpoint or a clearly named background maintenance action. -3. Add an HTTP regression test mirroring the MCP read-only observation-list behavior. - -### F-009 - Python pytest CI depends on Node but does not provision it in the test job - -Locations: - -- [ci.yml](/home/john/filigree/.github/workflows/ci.yml:47) lines 47-53: the separate frontend job provisions Node. -- [ci.yml](/home/john/filigree/.github/workflows/ci.yml:58) lines 58-68: the Python `test` job runs `uv run pytest` but does not set up Node. -- [test_dashboard_activity_state.py](/home/john/filigree/tests/static/test_dashboard_activity_state.py:18) lines 18-26: static pytest tests shell out to `node --input-type=module`. -- [test_dashboard_files_overview.py](/home/john/filigree/tests/static/test_dashboard_files_overview.py:13) lines 13-21: another pytest test uses Node. - -Problem: - -The pytest suite contains Node-backed tests, but the Python test job relies on whatever Node happens to exist on the runner image. The frontend job installs Node, but that setup does not carry into the Python test job. - -Impact: - -CI can become image-dependent and fail unexpectedly when runner images change. Locally, contributors may also see pytest failures that are not explained by the Python dependency setup. - -Remediation: - -1. Add `actions/setup-node` to the Python test job, or move the Node-backed static tests into the frontend job. -2. If keeping them in pytest, document Node as a test prerequisite in development docs. -3. Add a small guard that reports a clear skip/error if Node is unavailable, depending on whether these tests are required in CI. - -### F-010 - Live Clarion drift detection is manual-only in CI - -Locations: - -- [ci.yml](/home/john/filigree/.github/workflows/ci.yml:96) lines 96-112: the live Clarion job runs only for manual workflow dispatch with `require_live_clarion`. -- [test_clarion_phase_d_e2e.py](/home/john/filigree/tests/integration/test_clarion_phase_d_e2e.py:57) lines 57-67: the live test skips unless `FILIGREE_REQUIRE_LIVE_CLARION=1`. -- [test_sei_oracle_live_clarion.py](/home/john/filigree/tests/federation/test_sei_oracle_live_clarion.py:52) lines 52-54: the live SEI oracle test also skips unless required. - -Problem: - -Clarion is a cross-product trust boundary and registry source, but the live integration lane is opt-in manual-only. Normal PR CI exercises conformance and mocked paths, not a live Clarion deployment. - -Impact: - -Wire-level Clarion drift can merge if maintainers do not remember to run the manual job. This is not a direct code vulnerability, but it is a release-governance gap around a security-sensitive integration. - -Remediation: - -1. Add a scheduled live Clarion CI job against a pinned staging Clarion instance. -2. Keep the manual `require_live_clarion` option, but require scheduled success before release branches or tags. -3. Emit a release checklist warning if the live job has not passed since the relevant Filigree/Clarion contract changes. - -## Low Findings - -### F-011 - Dashboard project-cache locking serializes slow DB initialization - -Locations: - -- [dashboard.py](/home/john/filigree/src/filigree/dashboard.py:209) lines 209-233: `ProjectStore` owns a single process-wide lock. -- [dashboard.py](/home/john/filigree/src/filigree/dashboard.py:294) lines 294-317: `get_db()` performs project lookup/cache work while holding that lock. - -Problem: - -The project cache uses one lock for all projects. If DB open, migration, registry initialization, or eviction cleanup becomes slow under the lock, unrelated project requests can queue behind it. - -Impact: - -This is an availability and latency risk for multi-project dashboard/server-mode use. It is not currently shown to be exploitable, but it is a fragile concurrency boundary. - -Remediation: - -1. Split the critical section so slow DB opens occur outside the global map lock. -2. Use per-project locks or a placeholder/future entry to prevent duplicate opens while allowing other projects to proceed. -3. Add a concurrency test that proves a slow open for project A does not block a fast cached read for project B. - -### F-012 - MCP tool modules import transport-server runtime globals - -Locations: - -- [mcp_server.py](/home/john/filigree/src/filigree/mcp_server.py:105) lines 105-123: server runtime globals and request-local DB helpers live in the transport module. -- [mcp_tools/workflow.py](/home/john/filigree/src/filigree/mcp_tools/workflow.py:222) lines 222-249: workflow tools import `_get_db`, `_all_tools`, and other runtime globals from `mcp_server`. -- [mcp_server.py](/home/john/filigree/src/filigree/mcp_server.py:916) lines 916-938: the transport dispatch path serializes tool execution around server-held locks. - -Problem: - -Domain tool handlers reach back into the transport module for active DB state, tool registries, and status helpers. That couples tool logic to the server runtime and makes alternate transports or unit-level reuse harder. - -Impact: - -The coupling is architectural rather than an immediate bug. It increases the chance that transport changes alter tool behavior, and it makes it harder to test handlers in isolation. - -Remediation: - -1. Introduce a small MCP runtime/context object that exposes `db`, `filigree_dir`, safe-path helpers, and tool metadata. -2. Inject that context into handlers instead of importing `mcp_server` globals. -3. Keep transport-specific concerns, locks, and lifespan state in `mcp_server.py`. - -### F-013 - Scanner reporting paths have divergent CLI/MCP policy and normalization behavior - -Locations: - -- [mcp_tools/scanners.py](/home/john/filigree/src/filigree/mcp_tools/scanners.py:737) lines 737-834: MCP `report_finding` has its own parse, registry, warning, and observation-linking flow. -- [cli_commands/scanners.py](/home/john/filigree/src/filigree/cli_commands/scanners.py:1068) lines 1068-1256: CLI `report-finding` mirrors but does not share all behavior with MCP. -- [db_files.py](/home/john/filigree/src/filigree/db_files.py:1232) lines 1232-1255: observation creation is ultimately delegated through DB logic with path/file identity choices. - -Problem: - -The CLI and MCP scanner reporting surfaces duplicate policy and normalization steps around findings, registry resolution, warning propagation, and observation linking. - -Impact: - -Behavior can drift by surface. A scanner finding reported through MCP can receive different validation, warnings, or observation linkage than a finding reported through CLI. - -Remediation: - -1. Extract shared scanner-report orchestration into a service function that accepts a narrow context and returns a structured result. -2. Have CLI and MCP adapters handle only argument parsing and response formatting. -3. Add parity tests that feed the same finding through both adapters and compare the normalized DB record and observation linkage. - -### F-014 - Coverage and XSS guardrails are present but brittle for sensitive surfaces - -Locations: - -- [check_coverage_floors.py](/home/john/filigree/scripts/check_coverage_floors.py:15) lines 15-29: security-sensitive modules have explicit but low file-specific floors in some cases. -- [ci.yml](/home/john/filigree/.github/workflows/ci.yml:67) lines 67-68: CI runs total coverage and the floor script. -- [test_xss_guards.py](/home/john/filigree/tests/static/test_xss_guards.py:40) lines 40-43: XSS tests assert source substrings rather than executing DOM behavior. - -Problem: - -The quality gates are useful, but some floors for auth/MCP/security surfaces allow substantial untested space, and XSS tests check source text instead of runtime escaping behavior. - -Impact: - -Security regressions can pass if they do not move the exact source strings or if they occur in uncovered branches. This amplifies F-004 rather than standing alone as a direct vulnerability. - -Remediation: - -1. Raise floors for auth, registry, MCP validation, and scanner auth surfaces after adding targeted tests. -2. Replace source-string XSS checks with runtime DOM tests that render representative data and assert inert attributes/listeners. -3. Add specific regression tests for the issues in F-001, F-002, F-004, and F-005. - -### F-015 - `FILIGREE_API_TOKEN` protects only federation paths, which is easy to misread - -Locations: - -- [dashboard_auth.py](/home/john/filigree/src/filigree/dashboard_auth.py:1) lines 1-13: module docstring says the token gates the loom federation surface while leaving the classic surface and dashboard UI open. -- [dashboard_auth.py](/home/john/filigree/src/filigree/dashboard_auth.py:94) lines 94-103: middleware bypasses non-loom paths. -- [dashboard.py](/home/john/filigree/src/filigree/dashboard.py:646) lines 646-662: `FILIGREE_API_TOKEN` installs the federation auth middleware when non-empty. - -Problem: - -The code intentionally leaves classic dashboard routes open, but the environment variable name is broad enough that operators can infer it protects the whole API. - -Impact: - -This is an operator-confusion risk, especially when the same process exposes local dashboard, classic API, living aliases, MCP, and federation endpoints. It is lower severity because the source docstring is explicit and ADR-012 treats loopback as the default trust boundary. - -Remediation: - -1. Rename or supplement the variable with a more specific alias such as `FILIGREE_FEDERATION_API_TOKEN`. -2. Surface auth scope in `/api/health` or startup logs: federation auth enabled, dashboard/classic auth not enabled. -3. Document route classes in one operator-facing table: unauthenticated local/dashboard, gated federation, gated MCP. - -## Cross-Cutting Remediation Plan - -Recommended order: - -1. Fix auth-boundary defects first: F-001 and F-002. -2. Add scanner token propagation: F-003. -3. Remove inline event handlers or harden escaping: F-004. -4. Implement MCP `required` validation: F-005. -5. Repair state-history races and hidden read-side mutations: F-006 and F-008. -6. Normalize scanner observation identity: F-007. -7. Harden CI and governance guardrails: F-009, F-010, F-014. -8. Address structural risks as follow-up refactors: F-011, F-012, F-013, F-015. - -Suggested targeted tests before closing the high/medium items: - -- Unit tests for `ClarionRegistry` construction with token-bearing non-loopback URLs and redirects. -- API tests for `POST /api/observations` with and without `FILIGREE_API_TOKEN`. -- Scanner helper tests asserting `Authorization` is attached when configured. -- Dashboard runtime DOM tests for issue IDs containing quote and angle-bracket characters. -- MCP schema tests for missing required fields. -- SQLite two-connection dependency-removal race tests. -- HTTP observation-list tests proving GET does not sweep expired observations. -- CI job update proving Node-backed pytest tests run under an explicitly installed Node version. - -## Audit Limitations - -This was a strict read-only source audit. No tests, type checks, build commands, dashboard server, MCP server, scanner commands, or live Clarion calls were executed. The findings above are based on source review plus local line-range verification against the current tree. diff --git a/READ_ONLY_CODEBASE_AUDIT_2026-06-04.md b/READ_ONLY_CODEBASE_AUDIT_2026-06-04.md deleted file mode 100644 index c9d76923..00000000 --- a/READ_ONLY_CODEBASE_AUDIT_2026-06-04.md +++ /dev/null @@ -1,885 +0,0 @@ -# Filigree Read-Only Codebase Audit - -Date: 2026-06-04 - -Scope: `/home/john/filigree` - -Mode: Strict read-only review. No tests, services, migrations, or mutating tracker actions were run. The only write made by the coordinator is this requested markdown artifact. - -Subagents: seven specialized review agents were dispatched using read-only prompts that explicitly set `enable_write_tools=false` and `enable_mcp_tools=false` as operating constraints, disabled MCP usage, and prohibited file edits. The subagent API available in this session did not expose literal boolean parameters with those names, so those constraints were enforced in each agent prompt. Each returned report stated that no MCP tools or writes were used. - -Subagent roster: - -- Architecture Critic: `019e8f84-015c-7b80-bdc2-0ceafa0e3bbb` -- Systems Thinker: `019e8f84-0202-7440-9fbb-ec962967c108` -- Python Engineer: `019e8f84-029a-7572-b42f-e9733063e542` -- Quality Engineer: `019e8f84-0319-7382-99d5-03018b3e804e` -- Security Architect: `019e8f84-0432-7043-9f9a-d18b0ba4ca0e` -- Static Tools Analyst: `019e8f84-05eb-7e20-a90d-b37c9eb1a2db` -- MCP & CLI Specialist: `019e8f89-2eec-7932-b380-197f9eba4afb` - -Note on the requested static-analysis scope: `scanner/ast_primitives.py`, `scanner/rules/`, and rules `PY-WL-101` through `PY-WL-111` were not present in this repository. The Static Tools Analyst therefore audited the scanner/finding ingestion and static-analysis-adjacent surfaces that do exist. - -## Executive Summary - -No confirmed Critical findings were identified. - -The highest-risk issues are concentrated around trust boundaries and state transitions: - -- Clarion bearer tokens can be sent to a project-configured arbitrary URL. -- A token-protected scan-result ingestion path has an unprotected classic alias. -- MCP context resource reads bypass degraded-startup error gates. -- Some actor-bearing MCP/CLI paths bypass shared actor sanitization. -- Scanner state can be marked completed despite failures, or remain pending forever. -- Planning dependency writes can race into cycles, and critical-path reporting hides existing cycles. -- The dashboard app factory relies on mutable module globals, creating cross-project state bleed risk. - -## Critical - -No confirmed Critical findings. - -## High - -### H-01: Project-Controlled Clarion Base URL Can Exfiltrate Bearer Tokens - -Locations: - -- [/home/john/filigree/src/filigree/registry.py:534-544](/home/john/filigree/src/filigree/registry.py:534) -- [/home/john/filigree/src/filigree/core.py:1015-1033](/home/john/filigree/src/filigree/core.py:1015) -- [/home/john/filigree/src/filigree/core.py:1081-1095](/home/john/filigree/src/filigree/core.py:1081) -- [/home/john/filigree/src/filigree/registry.py:368-371](/home/john/filigree/src/filigree/registry.py:368) -- [/home/john/filigree/src/filigree/registry.py:382-395](/home/john/filigree/src/filigree/registry.py:382) - -Evidence: `normalize_clarion_base_url` accepts any `http` or `https` URL with a host. `FiligreeDB` reads the project config, resolves a Clarion token from an environment variable, builds `ClarionRegistry`, and immediately performs a capability probe. `_clarion_headers` attaches `Authorization: Bearer ...` to that request. - -Impact: An untrusted repository can configure `registry_backend=clarion` and point `clarion.base_url` at an attacker-controlled host. If the user has `CLARION_LOOM_TOKEN` or the configured token env var set, Filigree sends the token to that host. The same path is also an SSRF surface to arbitrary HTTP(S) origins. - -Remediation: - -- Treat Clarion origin as trusted user-local configuration, not repository-controlled data. -- Default to loopback-only Clarion URLs unless a non-repo allowlist explicitly permits another origin. -- Require HTTPS for non-loopback hosts. -- Disable redirects for token-bearing probes, or revalidate every redirect target before forwarding authorization headers. -- Bind tokens to expected origins and add regression tests for attacker URL, redirect URL, and loopback URL cases. - -### H-02: Token-Protected Scan Ingestion Is Bypassable Through Classic V1 Route - -Locations: - -- [/home/john/filigree/src/filigree/dashboard_auth.py:28-46](/home/john/filigree/src/filigree/dashboard_auth.py:28) -- [/home/john/filigree/src/filigree/dashboard_auth.py:91-96](/home/john/filigree/src/filigree/dashboard_auth.py:91) -- [/home/john/filigree/src/filigree/dashboard.py:584-616](/home/john/filigree/src/filigree/dashboard.py:584) -- [/home/john/filigree/src/filigree/dashboard_routes/files.py:485-508](/home/john/filigree/src/filigree/dashboard_routes/files.py:485) -- [/home/john/filigree/src/filigree/dashboard_routes/files.py:552-577](/home/john/filigree/src/filigree/dashboard_routes/files.py:552) - -Evidence: `FILIGREE_API_TOKEN` middleware only gates paths classified by `is_loom_scoped_path`, including `/api/loom/*` and `/api/scan-results`. Classic `POST /api/v1/scan-results` reaches the same ingestion behavior but is outside that protected path set. - -Impact: A local untrusted client can bypass configured bearer-token protection by posting scan results to `/api/v1/scan-results`, injecting findings and optionally creating observations. - -Remediation: - -- Gate scan ingestion by semantic capability rather than route generation. -- Require the same token on `/api/v1/scan-results`, `/api/scan-results`, and `/api/loom/...` scan-result aliases. -- Add auth parity tests that enumerate every scan-result route when `FILIGREE_API_TOKEN` is set. - -### H-03: MCP Context Resource Can Crash Outside Structured Error Handling - -Locations: - -- [/home/john/filigree/src/filigree/mcp_server.py:504-521](/home/john/filigree/src/filigree/mcp_server.py:504) -- [/home/john/filigree/src/filigree/mcp_server.py:705-725](/home/john/filigree/src/filigree/mcp_server.py:705) - -Evidence: `list_resources` always advertises `filigree://context`; `read_context` directly calls `generate_summary(_get_db())`. `call_tool` has explicit gates for schema mismatch, registry startup error, and DB open error, but the resource path does not. - -Impact: MCP clients following the prompt to read context first can receive an unstructured protocol exception during degraded startup, while tools return structured `ErrorResponse` envelopes. - -Remediation: - -- Gate `read_context` through the same degraded-startup checks as `call_tool`. -- Either hide the resource when unavailable or return a stable diagnostic resource with the same error code semantics as tools. -- Add resource-read tests for schema mismatch, registry mismatch, and uninitialized database startup. - -### H-04: MCP `release_my_claims` Bypasses Shared Actor Sanitization - -Locations: - -- [/home/john/filigree/src/filigree/mcp_tools/issues.py:1221-1256](/home/john/filigree/src/filigree/mcp_tools/issues.py:1221) -- [/home/john/filigree/src/filigree/db_issues.py:1691-1740](/home/john/filigree/src/filigree/db_issues.py:1691) -- [/home/john/filigree/src/filigree/validation.py:14-33](/home/john/filigree/src/filigree/validation.py:14) -- [/home/john/filigree/src/filigree/cli_commands/issues.py:1002-1046](/home/john/filigree/src/filigree/cli_commands/issues.py:1002) - -Evidence: The MCP handler only requires `actor` to be a non-empty string after `.strip()`. `release_my_claims` repeats that minimal check. Shared `sanitize_actor` rejects control/format characters and actors over 128 characters. - -Impact: MCP can accept newline/control-character or overlong actor identities that other issue tools reject. Because this operation releases claims and records audit events, invalid actors can corrupt attribution and line-oriented audit/log consumers. - -Remediation: - -- Replace the local strip-only actor handling with `_validate_actor(raw_actor)` in `_handle_release_my_claims`. -- Enforce `sanitize_actor` inside `db_issues.release_my_claims` as defense in depth. -- Add MCP tests for blank, control-character, format-character, and overlong actor values. - -### H-05: Scanner Failures Can Still Mark Scan Runs Completed - -Locations: - -- [/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:640-686](/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:640) -- [/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:860-900](/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:860) - -Evidence: `_analyse_files` records executor/report/API failures but still sends a final empty `complete_scan_run=True` POST whenever `scan_run_id` is set. `run_scanner_pipeline` returns a nonzero exit code only after that final completion request. - -Impact: A reserved scan run can show `completed` even when the scanner failed, missed reports, or dropped finding POSTs. Status polling and cooldown logic then operate on a false success. - -Remediation: - -- Send completion only when executor failures and API failures are zero. -- Add a failure-status callback/API path for scanner-side failures. -- Add tests for scanner executor failure, failed finding POST, and report parse failure to ensure the scan run does not become `completed`. - -### H-06: Stale Pending Scan Reservations Can Permanently Block Future Scans - -Locations: - -- [/home/john/filigree/src/filigree/db_scans.py:79-135](/home/john/filigree/src/filigree/db_scans.py:79) -- [/home/john/filigree/src/filigree/db_scans.py:228-301](/home/john/filigree/src/filigree/db_scans.py:228) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:839-908](/home/john/filigree/src/filigree/mcp_tools/scanners.py:839) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:1104-1214](/home/john/filigree/src/filigree/mcp_tools/scanners.py:1104) - -Evidence: `reserve_scan_run` inserts `pending` rows with `pid=NULL`. `check_scan_cooldown` treats `pending` and `running` rows as singleton locks regardless of age. `get_scan_status` auto-fails only `running` rows with a PID. - -Impact: A crash after reservation but before spawn/backfill leaves an unreconciled pending row that blocks future scans for the scanner/file pair. - -Remediation: - -- Add a TTL/reconciliation path for stale `pending` reservations. -- Surface stale reservations in status output and provide a repair command. -- Add a regression test for crash-after-reserve or spawn-before-backfill failure. - -### H-07: Concurrent Dependency Writes Can Create Planning Cycles - -Locations: - -- [/home/john/filigree/src/filigree/db_planning.py:219-280](/home/john/filigree/src/filigree/db_planning.py:219) -- [/home/john/filigree/src/filigree/db_planning.py:680-720](/home/john/filigree/src/filigree/db_planning.py:680) -- [/home/john/filigree/src/filigree/db_planning.py:742-780](/home/john/filigree/src/filigree/db_planning.py:742) - -Evidence: Dependency add/retarget paths check for cycles before insert or delete-plus-insert. The check and write are not serialized in one immediate transaction. - -Impact: Two concurrent writers can both pass `_would_create_cycle` and insert opposing edges. The resulting cycle can deadlock readiness and corrupt planning invariants. - -Remediation: - -- Wrap cycle check plus mutation in a retrying `BEGIN IMMEDIATE` transaction. -- Recheck the graph under the write lock immediately before insert. -- Add concurrent tests for opposing A->B and B->A insertions. -- Consider a recursive trigger or post-write SCC integrity check as defense in depth. - -### H-08: Critical Path Reporting Silently Hides Cyclic Planning Graphs - -Locations: - -- [/home/john/filigree/src/filigree/db_planning.py:415-483](/home/john/filigree/src/filigree/db_planning.py:415) - -Evidence: `get_critical_path` uses a Kahn-style topological traversal but never verifies that every node was processed. If the graph is cyclic, the queue can empty while cyclic nodes remain unprocessed, and the function can return an empty or misleading path. - -Impact: Once a cycle exists, the command that should help operators diagnose the planning graph can instead hide the cycle. - -Remediation: - -- Track processed node count and compare it to graph node count. -- If unprocessed nodes remain, return or raise a typed integrity error with the cycle/SCC members. -- Add tests for all-cyclic and mixed cyclic/acyclic graphs. - -### H-09: Dashboard App Factory Relies On Mutable Module-Global Runtime State - -Locations: - -- [/home/john/filigree/src/filigree/dashboard.py:73-100](/home/john/filigree/src/filigree/dashboard.py:73) -- [/home/john/filigree/src/filigree/dashboard.py:389-425](/home/john/filigree/src/filigree/dashboard.py:389) -- [/home/john/filigree/src/filigree/dashboard.py:809-823](/home/john/filigree/src/filigree/dashboard.py:809) - -Evidence: `_db`, `_config`, `_allow_http_force_close`, `_current_project_key`, and `_project_store` are module globals. `_get_db()` changes behavior based on these globals, and `main()` explicitly clears them because a later run can otherwise serve the wrong database. - -Impact: Multiple dashboard apps in one process, tests, or embedded deployments can bleed state across projects or modes. This can route requests to the wrong tracker database. - -Remediation: - -- Introduce a `DashboardState` object stored on `app.state`. -- Pass database/project-store dependencies into router factories instead of resolving module globals. -- Remove module-global request resolution. -- Add tests creating two apps in one process and proving request isolation. - -## Medium - -### M-01: Dashboard-Mounted MCP HTTP Endpoint Is Not Protected By Bearer Token - -Locations: - -- [/home/john/filigree/src/filigree/dashboard.py:584-616](/home/john/filigree/src/filigree/dashboard.py:584) -- [/home/john/filigree/src/filigree/dashboard.py:724-753](/home/john/filigree/src/filigree/dashboard.py:724) -- [/home/john/filigree/src/filigree/mcp_server.py:806-930](/home/john/filigree/src/filigree/mcp_server.py:806) -- [/home/john/filigree/src/filigree/dashboard.py:902-902](/home/john/filigree/src/filigree/dashboard.py:902) - -Evidence: Dashboard auth middleware only enforces `is_loom_scoped_path`, which is `/api/...` scoped. The dashboard mounts `/mcp` separately and forwards it to the MCP HTTP session manager. The server binds to `127.0.0.1`, reducing remote exposure but not protecting against local untrusted clients. - -Impact: Any local process able to reach the dashboard port can invoke the HTTP MCP surface, including write-capable tracker tools, without the configured API token. - -Remediation: - -- Require bearer auth on `/mcp` when `FILIGREE_API_TOKEN` is set, or add a separate MCP HTTP token. -- Consider disabling dashboard-mounted MCP by default unless explicitly enabled. -- Add tests for `/mcp` with and without token configuration. - -### M-02: Project-Local Scanner TOML Can Execute Arbitrary Processes When Triggered - -Locations: - -- [/home/john/filigree/src/filigree/scanners.py:165-184](/home/john/filigree/src/filigree/scanners.py:165) -- [/home/john/filigree/src/filigree/scanners.py:190-252](/home/john/filigree/src/filigree/scanners.py:190) -- [/home/john/filigree/src/filigree/scanner_runtime.py:32-91](/home/john/filigree/src/filigree/scanner_runtime.py:32) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:770-884](/home/john/filigree/src/filigree/mcp_tools/scanners.py:770) -- [/home/john/filigree/src/filigree/cli_commands/scanners.py:372-453](/home/john/filigree/src/filigree/cli_commands/scanners.py:372) - -Evidence: Scanner configs are loaded from `.filigree/scanners/*.toml`; `command` and `args` become process argv. Metadata supports `requires_approval`, but trigger paths validate command existence and call `subprocess.Popen`. - -Impact: In an untrusted repository, a malicious scanner definition can point to a repo-local executable or arbitrary PATH command. This is not shell injection, but it is repo-configured code execution once a scanner is triggered. - -Remediation: - -- Enforce approval metadata before spawning. -- Default to bundled/managed scanners only. -- Require a trusted-scanner allowlist stored outside the repository. -- Reject repo-relative executables unless an explicit trust flag is passed. - -### M-03: Scan-Result Ingestion Lacks Body, Count, And String-Size Limits - -Locations: - -- [/home/john/filigree/src/filigree/dashboard_routes/common.py:113-122](/home/john/filigree/src/filigree/dashboard_routes/common.py:113) -- [/home/john/filigree/src/filigree/dashboard_routes/files.py:171-209](/home/john/filigree/src/filigree/dashboard_routes/files.py:171) -- [/home/john/filigree/src/filigree/db_files.py:838-909](/home/john/filigree/src/filigree/db_files.py:838) -- [/home/john/filigree/src/filigree/db_files.py:1074-1125](/home/john/filigree/src/filigree/db_files.py:1074) -- [/home/john/filigree/src/filigree/registry.py:744-746](/home/john/filigree/src/filigree/registry.py:744) - -Evidence: HTTP routes parse the whole JSON body with `request.json()`. Finding validation checks types and project-relative paths but does not cap finding count, string lengths, metadata size/depth, or path count. Clarion batch resolution chunks all unique paths rather than rejecting oversized requests. - -Impact: A local caller can force large memory allocations, many validations, database writes, observation creation, and potentially many Clarion batch calls. - -Remediation: - -- Add request body-size limits. -- Add maximum findings per request. -- Add maximum string lengths for message, suggestion, rule ID, file path, metadata keys/values, and evidence fields. -- Add maximum metadata depth/size. -- Require a reserved scan run or per-run callback secret for ingestion. - -### M-04: Scan Ingestion Mutates Findings And Observations Without Refreshing Agent Context - -Locations: - -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:663-767](/home/john/filigree/src/filigree/mcp_tools/scanners.py:663) -- [/home/john/filigree/src/filigree/cli_commands/scanners.py:1003-1208](/home/john/filigree/src/filigree/cli_commands/scanners.py:1003) -- [/home/john/filigree/src/filigree/dashboard_routes/files.py:485-507](/home/john/filigree/src/filigree/dashboard_routes/files.py:485) -- [/home/john/filigree/src/filigree/dashboard_routes/files.py:552-579](/home/john/filigree/src/filigree/dashboard_routes/files.py:552) -- [/home/john/filigree/src/filigree/db_files.py:1235-1278](/home/john/filigree/src/filigree/db_files.py:1235) -- [/home/john/filigree/src/filigree/summary.py:300-315](/home/john/filigree/src/filigree/summary.py:300) - -Evidence: Scanner/report and HTTP scan-result paths call `process_scan_results` and return. Paired observations can be created inside ingestion. Other observation MCP paths explicitly refresh summary, but scan ingestion does not consistently do so. - -Impact: Scanner-created observations and finding-driven issue cascades can exist in the DB while `context.md` remains stale, causing agents to miss pending work at session start. - -Remediation: - -- Centralize summary invalidation/refresh after tracker mutations. -- Refresh summary after successful scan ingestion on MCP, CLI, and HTTP surfaces. -- Add tests for `report_finding --create-observation` and `/scan-results` summary freshness. - -### M-05: Finding-To-Issue Cascades Are Post-Commit And Best-Effort - -Locations: - -- [/home/john/filigree/src/filigree/db_files.py:1352-1465](/home/john/filigree/src/filigree/db_files.py:1352) -- [/home/john/filigree/src/filigree/db_files.py:1897-1919](/home/john/filigree/src/filigree/db_files.py:1897) -- [/home/john/filigree/src/filigree/db_files.py:1972-1998](/home/john/filigree/src/filigree/db_files.py:1972) - -Evidence: Scan ingest commits before reopening linked issues; stale-finding cleanup commits before closing linked issues. Cascade failures are appended as warnings rather than failing the original mutation or persisting reconciliation debt. - -Impact: A finding can be open while its linked issue remains closed, or fixed while its issue remains open. If warnings are ignored, operators see contradictory global state. - -Remediation: - -- Persist cascade failures as durable reconciliation items. -- Surface reconciliation debt in summary, dashboard, and stats. -- Add retry/repair tooling. -- Add fault-injection tests for close/reopen cascade failures. - -### M-06: Observation Promotion Splits Issue Creation From Cleanup And Enrichment - -Locations: - -- [/home/john/filigree/src/filigree/db_observations.py:747-835](/home/john/filigree/src/filigree/db_observations.py:747) -- [/home/john/filigree/src/filigree/db_observations.py:875-990](/home/john/filigree/src/filigree/db_observations.py:875) -- [/home/john/filigree/src/filigree/db_observations.py:1029-1202](/home/john/filigree/src/filigree/db_observations.py:1029) - -Evidence: Observation promotion commits the issue, then separately links/deletes observations and adds audit/labels/file associations as best-effort follow-up. - -Impact: A failed cleanup leaves both a tracked issue and a live observation, creating duplicate triage signals. Failed enrichment can hide the issue's observation origin or file context. - -Remediation: - -- Make cleanup/enrichment failures durable and queryable. -- Refresh the returned issue after enrichment. -- Add fault-injection tests for delete, link, label, and file-association failures. - -### M-07: Status Reads Can Mutate Scan State - -Locations: - -- [/home/john/filigree/src/filigree/db_scans.py:263-301](/home/john/filigree/src/filigree/db_scans.py:263) -- [/home/john/filigree/src/filigree/db_scans.py:164-227](/home/john/filigree/src/filigree/db_scans.py:164) - -Evidence: `get_scan_status` performs live PID checks and calls `update_scan_run_status(..., "failed")` when a running process is dead. - -Impact: Monitoring or polling changes system state. Operators and MCP hosts may not expect a read/status path to perform reconciliation writes. - -Remediation: - -- Split pure read from reconciliation, for example `get_scan_status(reconcile=False)` plus explicit `reconcile_scan_status`. -- Record explicit reconciliation events. -- Mark MCP/CLI status tools correctly if reconciliation remains enabled. - -### M-08: Summary Rendering Can Grow With Unbounded WIP And Stale Sections - -Locations: - -- [/home/john/filigree/src/filigree/summary.py:70-80](/home/john/filigree/src/filigree/summary.py:70) -- [/home/john/filigree/src/filigree/summary.py:171-213](/home/john/filigree/src/filigree/summary.py:171) - -Evidence: `in_progress` loads up to 10,000 issues; In Progress and Stale sections iterate all matching items while Ready and Blocked are capped. - -Impact: Large projects can make every summary refresh expensive and produce a context file too large for quick agent orientation. - -Remediation: - -- Cap rendered WIP/stale sections. -- Include totals and query hints for omitted items. -- Add a summary size-budget test. - -### M-09: MCP `observation_list` Is Marked Read-Only But Sweeps Expired Rows - -Locations: - -- [/home/john/filigree/src/filigree/mcp_server.py:415-429](/home/john/filigree/src/filigree/mcp_server.py:415) -- [/home/john/filigree/src/filigree/mcp_server.py:441-449](/home/john/filigree/src/filigree/mcp_server.py:441) -- [/home/john/filigree/src/filigree/mcp_tools/observations.py:403-433](/home/john/filigree/src/filigree/mcp_tools/observations.py:403) -- [/home/john/filigree/src/filigree/db_observations.py:440-526](/home/john/filigree/src/filigree/db_observations.py:440) -- [/home/john/filigree/src/filigree/db_observations.py:602-615](/home/john/filigree/src/filigree/db_observations.py:602) - -Evidence: MCP read-only inference marks `list_*` tools read-only. `list_observations` calls tracker `list_observations`, which sweeps expired observations. A separate stats path documents `sweep=False`, confirming that sweep behavior is not required for all reads. - -Impact: MCP hosts can invoke a mutating cleanup path under read-only assumptions. - -Remediation: - -- Make MCP `list_observations` use a no-sweep list path and filter expired rows in memory, or remove `readOnlyHint` for this tool. -- Add tests asserting read-only tool annotations only for non-mutating handlers. - -### M-10: MCP `report_finding` Severity Type Can Crash Before Error Envelope - -Locations: - -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:663-681](/home/john/filigree/src/filigree/mcp_tools/scanners.py:663) -- [/home/john/filigree/tests/api/test_scanner_tools.py:493-506](/home/john/filigree/tests/api/test_scanner_tools.py:493) -- [/home/john/filigree/tests/cli/test_scanners_commands.py:1748-1772](/home/john/filigree/tests/cli/test_scanners_commands.py:1748) - -Evidence: The handler checks `severity not in VALID_SEVERITIES` without first ensuring severity is a string. An unhashable JSON array/object can raise `TypeError` before a structured MCP validation response. Tests cover invalid string severity, and CLI has a regression for this class of issue, but MCP does not. - -Impact: Bad MCP input can produce an unstructured exception instead of a typed validation error. - -Remediation: - -- Check `isinstance(severity, str)` before membership testing. -- Add MCP regression tests for `severity=[]` and `severity={}`. - -### M-11: `report_finding` Actor Attribution Bypasses Shared Validation - -Locations: - -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:663-715](/home/john/filigree/src/filigree/mcp_tools/scanners.py:663) -- [/home/john/filigree/src/filigree/cli_commands/scanners.py:975-1002](/home/john/filigree/src/filigree/cli_commands/scanners.py:975) -- [/home/john/filigree/src/filigree/cli_commands/scanners.py:1122-1132](/home/john/filigree/src/filigree/cli_commands/scanners.py:1122) -- [/home/john/filigree/src/filigree/db_files.py:1242-1264](/home/john/filigree/src/filigree/db_files.py:1242) -- [/home/john/filigree/src/filigree/db_observations.py:222-256](/home/john/filigree/src/filigree/db_observations.py:222) - -Evidence: MCP and CLI `report_finding` strip an optional actor and pass it as `observation_actor`. `process_scan_results` uses that value when creating observations. `create_observation` does not apply shared `sanitize_actor`. The CLI command also ignores the global `--actor` flow for this paired observation path. - -Impact: Scan findings can create observations with actors that other observation and issue tools would reject, weakening audit consistency. - -Remediation: - -- Validate provided actors with `sanitize_actor` or MCP `_validate_actor`. -- In CLI, use `@click.pass_context`, default local actor to `ctx.obj["actor"]`, and sanitize before passing to ingestion. -- Add CLI and MCP tests for invalid actors with `create_observation`. - -### M-12: HTTP MCP Schema Mismatch Uses The Wrong HTTP Status - -Locations: - -- [/home/john/filigree/src/filigree/mcp_server.py:852-859](/home/john/filigree/src/filigree/mcp_server.py:852) -- [/home/john/filigree/src/filigree/types/api.py:743-755](/home/john/filigree/src/filigree/types/api.py:743) - -Evidence: The HTTP MCP app returns `409` for `SchemaVersionMismatchError`, while central `errorcode_to_http_status` maps `ErrorCode.SCHEMA_MISMATCH` to `503`. - -Impact: HTTP MCP/dashboard clients receive inconsistent transport semantics for the same typed error and may retry or report the wrong remediation. - -Remediation: - -- Return `errorcode_to_http_status(ErrorCode.SCHEMA_MISMATCH)` in the MCP HTTP schema mismatch branch. -- Add a startup test asserting both JSON error code and HTTP status. - -### M-13: Several CLI Validators Bypass JSON Error Envelopes - -Locations: - -- [/home/john/filigree/src/filigree/cli_commands/meta.py:312-323](/home/john/filigree/src/filigree/cli_commands/meta.py:312) -- [/home/john/filigree/src/filigree/cli_commands/meta.py:403-417](/home/john/filigree/src/filigree/cli_commands/meta.py:403) -- [/home/john/filigree/src/filigree/cli_commands/meta.py:493-510](/home/john/filigree/src/filigree/cli_commands/meta.py:493) -- [/home/john/filigree/src/filigree/cli_commands/files.py:931-951](/home/john/filigree/src/filigree/cli_commands/files.py:931) -- [/home/john/filigree/src/filigree/cli_commands/files.py:1046-1055](/home/john/filigree/src/filigree/cli_commands/files.py:1046) -- [/home/john/filigree/src/filigree/cli_commands/files.py:1106-1116](/home/john/filigree/src/filigree/cli_commands/files.py:1106) -- [/home/john/filigree/src/filigree/cli_commands/files.py:1176-1186](/home/john/filigree/src/filigree/cli_commands/files.py:1176) -- [/home/john/filigree/src/filigree/cli_commands/files.py:1224-1239](/home/john/filigree/src/filigree/cli_commands/files.py:1224) - -Evidence: These commands use Click `IntRange` and `Choice` validation at parse time. Parse-time failures occur before command bodies can emit structured JSON `{error, code}` responses, unlike MCP handlers that validate inside handlers. - -Impact: Automation using `filigree ... --json` cannot reliably parse validation failures across CLI surfaces, and CLI/MCP behavior diverges for comparable invalid arguments. - -Remediation: - -- Move validation for JSON-capable commands into command bodies, or add a top-level Click error adapter that emits structured JSON whenever `--json` was requested. -- Add parity tests for invalid enum/range values across CLI and MCP. - -### M-14: Invalid Line Normalization Can Merge Distinct Findings - -Locations: - -- [/home/john/filigree/src/filigree/db_files.py:919-960](/home/john/filigree/src/filigree/db_files.py:919) -- [/home/john/filigree/src/filigree/db_files.py:1170-1195](/home/john/filigree/src/filigree/db_files.py:1170) -- [/home/john/filigree/tests/core/test_files.py:1360-1390](/home/john/filigree/tests/core/test_files.py:1360) - -Evidence: `_normalize_line_attribution_for_existing_files` clears out-of-range `line_start`/`line_end`. Legacy dedup uses a key with `coalesce(line_start, -1)`. Existing tests assert clearing behavior but not collisions between multiple bad-line findings. - -Impact: Distinct findings with invalid line attribution can collapse onto the same dedup key. - -Remediation: - -- Reject out-of-range line attribution instead of clearing it, or require a stable fingerprint when clearing. -- Include message/evidence hash in dedup for unknown-line findings. -- Add tests for two findings on the same file/rule with different invalid lines. - -### M-15: Markdown Finding Parser Can Drop Valid Findings And Misattribute Lines - -Locations: - -- [/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:248-314](/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:248) -- [/home/john/filigree/tests/util/test_scan_utils.py:940-990](/home/john/filigree/tests/util/test_scan_utils.py:940) - -Evidence: `parse_findings` skips a section if it contains the substring `No concrete bug found` anywhere. Line extraction uses the first `:(\d+)` in evidence, so ports, timestamps, URLs, or unrelated citations can become `line_start`. - -Impact: Valid findings can be dropped or attached to the wrong line, then normalized/merged later. - -Remediation: - -- Treat the no-finding sentinel as an exact section marker, not a substring anywhere in evidence. -- Parse explicit file path/line citation grammar, preferably matching the reported `file_path`. -- Prefer JSON/SARIF scanner output where possible. - -### M-16: Parent-Cycle Traversal Can Hang On Pre-Existing Parent Loops - -Locations: - -- [/home/john/filigree/src/filigree/db_issues.py:398-415](/home/john/filigree/src/filigree/db_issues.py:398) - -Evidence: `_would_create_parent_cycle` walks parent links without tracking visited ancestors. - -Impact: If the database already contains a parent loop, subsequent parent-cycle checks can loop indefinitely. - -Remediation: - -- Track visited issue IDs during traversal. -- Treat revisiting an ancestor as an existing integrity error. -- Add a repair/report path for parent loops. - -### M-17: Scanner TOML Loader Crashes On Invalid UTF-8 - -Locations: - -- [/home/john/filigree/src/filigree/scanners.py:203-209](/home/john/filigree/src/filigree/scanners.py:203) - -Evidence: `_parse_toml` catches `OSError` around `path.read_text(encoding="utf-8")`, but not `UnicodeDecodeError`. - -Impact: One malformed scanner file can break scanner listing/loading instead of being skipped like other malformed TOML. - -Remediation: - -- Catch `UnicodeDecodeError` with `OSError`. -- Append a scanner load error and return `None`. -- Add a malformed UTF-8 scanner fixture test. - -### M-18: `FileRecord` Runtime Validation Accepts Invalid Registry Backends - -Locations: - -- [/home/john/filigree/src/filigree/models.py:148-162](/home/john/filigree/src/filigree/models.py:148) - -Evidence: `FileRecord.__post_init__` checks only the `local`/empty-hash correlation. A row with `registry_backend="bogus"` and non-empty `content_hash` passes despite the `RegistryBackend` literal contract. - -Impact: Corrupt DB rows or bad migrations can leak invalid API data. - -Remediation: - -- Validate `registry_backend in get_args(RegistryBackend)`. -- Add tests for invalid backend with and without content hash. - -### M-19: Config JSON Is Cast To `ProjectConfig` Without Shape Validation - -Locations: - -- [/home/john/filigree/src/filigree/core.py:556-576](/home/john/filigree/src/filigree/core.py:556) - -Evidence: `read_config` assigns raw JSON to `ProjectConfig` with a type ignore, then validates only registry settings. `enabled_packs`, `prefix`, and `version` types are not fully checked before later use. - -Impact: A malformed `.filigree/config.json` can crash startup or silently alter enabled pack behavior. - -Remediation: - -- Reuse `read_conf`-style validation for `prefix`, `version`, and `enabled_packs`. -- Return defaults or raise a clear typed/structured error consistently. -- Add malformed-config tests. - -### M-20: API Success Responses Can Crash Scanner Ingestion - -Locations: - -- [/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:350-394](/home/john/filigree/src/filigree/scanner_scripts/scan_utils.py:350) - -Evidence: `post_to_api` assumes every 2xx response is a JSON object and then calls `body.get(...)`. Decode errors, Unicode errors, or JSON arrays are not handled. - -Impact: A proxy, dashboard bug, or HTML success response can crash the scanner process instead of returning controlled failure details. - -Remediation: - -- Catch JSON/Unicode/type errors. -- Require `isinstance(body, dict)` before accessing fields. -- Return `(False, detail)` for malformed success responses. - -### M-21: Clarion Capability Probe Uses Different HTTP Policy Than Runtime Resolution - -Locations: - -- [/home/john/filigree/src/filigree/registry.py:392-396](/home/john/filigree/src/filigree/registry.py:392) -- [/home/john/filigree/src/filigree/registry.py:613-613](/home/john/filigree/src/filigree/registry.py:613) - -Evidence: Capability probing uses `urllib.request.urlopen`, while runtime Clarion resolution uses `httpx.Client(trust_env=False, follow_redirects=True)`. - -Impact: Startup probe behavior can differ from file resolution under proxy, environment, and redirect settings. This also complicates token-origin controls. - -Remediation: - -- Use the same `httpx` client policy for capability probes and runtime resolution. -- Explicitly set proxy and redirect policy for token-bearing requests. -- Add tests for proxy env and redirect behavior if feasible. - -### M-22: MCP Tool Modules Depend On Private `mcp_server` Globals - -Locations: - -- [/home/john/filigree/src/filigree/mcp_server.py:63-77](/home/john/filigree/src/filigree/mcp_server.py:63) -- [/home/john/filigree/src/filigree/mcp_tools/issues.py:930-960](/home/john/filigree/src/filigree/mcp_tools/issues.py:930) -- [/home/john/filigree/src/filigree/mcp_tools/files.py:734-765](/home/john/filigree/src/filigree/mcp_tools/files.py:734) - -Evidence: Tool handlers import `_get_db` and `_refresh_summary` from `filigree.mcp_server` inside functions, tying domain tool modules to one transport/composition module. - -Impact: Reuse, isolated testing, and alternate MCP/HTTP wiring are fragile. - -Remediation: - -- Define a `ToolContext` or handler factory in `mcp_tools.common`. -- Have `mcp_server` inject DB, path resolver, logger, and summary-refresh behavior. -- Remove private imports from tool modules. - -### M-23: DB Mixins Are Coupled Through A Broad MRO Contract - -Locations: - -- [/home/john/filigree/src/filigree/db_base.py:260-509](/home/john/filigree/src/filigree/db_base.py:260) -- [/home/john/filigree/src/filigree/db_files.py:1352-1597](/home/john/filigree/src/filigree/db_files.py:1352) -- [/home/john/filigree/src/filigree/db_files.py:1829-1907](/home/john/filigree/src/filigree/db_files.py:1829) - -Evidence: `DBMixinProtocol` declares cross-domain methods for issues, workflow, meta, files, observations, scans, and planning. `FilesMixin` handles scan ingestion, registry resolution, finding lifecycle, observation promotion, and issue close/reopen cascades. - -Impact: Package boundaries are porous; changes in one domain can require coordinated updates across many mixins and protocol declarations. - -Remediation: - -- Split orchestration from persistence. -- Keep repositories narrow and move scan ingestion plus finding-to-issue cascade into explicit service classes. -- Add service-level tests around cross-domain workflows. - -### M-24: MCP Runtime Validation Is Split Across Schemas, Casts, And Handlers - -Locations: - -- [/home/john/filigree/src/filigree/types/inputs.py:5-14](/home/john/filigree/src/filigree/types/inputs.py:5) -- [/home/john/filigree/src/filigree/mcp_tools/common.py:29-36](/home/john/filigree/src/filigree/mcp_tools/common.py:29) -- [/home/john/filigree/src/filigree/mcp_server.py:483-499](/home/john/filigree/src/filigree/mcp_server.py:483) -- [/home/john/filigree/src/filigree/mcp_tools/issues.py:930-960](/home/john/filigree/src/filigree/mcp_tools/issues.py:930) - -Evidence: TypedDicts mirror JSON Schema, `_parse_args()` mostly casts, and dispatch only rejects non-object or unknown arguments before handlers do individual checks. - -Impact: Correct behavior relies on external SDK validation and per-handler checks staying aligned; direct/internal calls can fail inconsistently, as shown by the `report_finding` severity type issue. - -Remediation: - -- Add one dispatch-level JSON Schema or generated model validator before handler invocation. -- Pass validated/coerced inputs to handlers. -- Add tests for malformed types on representative tools. - -### M-25: Coverage Floors Omit Several High-Risk Surfaces - -Locations: - -- [/home/john/filigree/.github/workflows/ci.yml:45-61](/home/john/filigree/.github/workflows/ci.yml:45) -- [/home/john/filigree/pyproject.toml:171-186](/home/john/filigree/pyproject.toml:171) -- [/home/john/filigree/scripts/check_coverage_floors.py:15-23](/home/john/filigree/scripts/check_coverage_floors.py:15) - -Evidence: CI enforces total 85% coverage and a short list of file-specific floors. Several high-risk surfaces in this report, including dashboard auth, registry/Clarion token handling, MCP server startup/resource handling, scanner runtime, and scan-result ingestion, do not have explicit file floors. - -Impact: Regression in thin but security-critical surfaces can be hidden by stronger coverage elsewhere. - -Remediation: - -- Add floors for `dashboard_auth.py`, `dashboard_routes/files.py`, `mcp_server.py`, `registry.py`, `scanner_runtime.py`, and `scanner_scripts/scan_utils.py`. -- Pair floors with behavior-focused tests for the High/Medium issues in this report. - -### M-26: Live Clarion Integration Coverage Is Optional In CI - -Locations: - -- [/home/john/filigree/.github/workflows/ci.yml:45-61](/home/john/filigree/.github/workflows/ci.yml:45) -- [/home/john/filigree/tests/integration/test_clarion_phase_d_e2e.py:53-93](/home/john/filigree/tests/integration/test_clarion_phase_d_e2e.py:53) -- [/home/john/filigree/tests/federation/test_sei_oracle_live_clarion.py:52-122](/home/john/filigree/tests/federation/test_sei_oracle_live_clarion.py:52) -- [/home/john/filigree/src/filigree/registry.py:382-470](/home/john/filigree/src/filigree/registry.py:382) -- [/home/john/filigree/src/filigree/registry.py:615-780](/home/john/filigree/src/filigree/registry.py:615) - -Evidence: CI runs standard pytest but does not show a required live Clarion service stage. The live Clarion tests are present but environment-dependent. - -Impact: Cross-product registry drift, auth semantics, and capability-probe behavior can ship without being exercised against a real Clarion endpoint. - -Remediation: - -- Add a required mocked-contract suite for Clarion auth/capabilities and path resolution. -- If live Clarion remains optional, add a scheduled or gated live job with clear failure triage. -- Include redirect and token-origin tests from H-01. - -### M-27: Contract Parity Helper Checks Only The First List Item - -Locations: - -- [/home/john/filigree/tests/util/test_generation_parity.py:66-101](/home/john/filigree/tests/util/test_generation_parity.py:66) -- [/home/john/filigree/tests/util/test_generation_parity.py:107-129](/home/john/filigree/tests/util/test_generation_parity.py:107) - -Evidence: The parity helper validates representative list contents by inspecting only the first item. - -Impact: Generated API/adapter contracts can drift for later list elements without the parity test catching it. - -Remediation: - -- Validate every list item, or at least sample first, middle, and last with explicit length checks. -- Add a negative test where the second item differs from the expected shape. - -### M-28: Scanner Subprocess Behavior Is Heavily Mocked - -Locations: - -- [/home/john/filigree/src/filigree/scanner_runtime.py:31-93](/home/john/filigree/src/filigree/scanner_runtime.py:31) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:770-908](/home/john/filigree/src/filigree/mcp_tools/scanners.py:770) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:1104-1214](/home/john/filigree/src/filigree/mcp_tools/scanners.py:1104) - -Evidence: The high-risk behavior here is process spawn, reservation, PID/log backfill, and completion callback ordering. Subagent review found mocked coverage but no dynamic crash-after-reserve or failure-callback test. - -Impact: Races like H-05 and H-06 are easy to miss if tests do not execute realistic subprocess lifecycle edges. - -Remediation: - -- Add integration tests with a tiny controlled scanner process. -- Exercise spawn failure, crash before callback, callback failure, PID death, and stale pending repair. - -## Low - -### L-01: MCP Tool Annotations Understate Destructive And Admin Behavior - -Locations: - -- [/home/john/filigree/src/filigree/mcp_server.py:415-450](/home/john/filigree/src/filigree/mcp_server.py:415) -- [/home/john/filigree/src/filigree/mcp_tools/meta.py:340-419](/home/john/filigree/src/filigree/mcp_tools/meta.py:340) -- [/home/john/filigree/src/filigree/mcp_tools/scanners.py:295-320](/home/john/filigree/src/filigree/mcp_tools/scanners.py:295) -- [/home/john/filigree/src/filigree/mcp_tools/issues.py:717-803](/home/john/filigree/src/filigree/mcp_tools/issues.py:717) - -Evidence: `_DESTRUCTIVE_TOOLS` only includes `delete_issue` and `delete_file_record`. Other exposed tools can import tracker state, archive closed issues, compact events, undo admin actions, restart dashboard processes, disable scanners, or batch-close items. - -Impact: MCP hosts that use `ToolAnnotations` for safety prompts may treat high-impact admin operations as ordinary mutating tools. - -Remediation: - -- Expand annotation classification for import, compact, archive, undo, restart, disable, and batch mutation tools. -- Add a static assertion requiring every admin/batch mutation tool to have an explicit annotation decision. - -### L-02: Workflow Seeding Has A Runtime Circular Dependency On `core.py` - -Locations: - -- [/home/john/filigree/src/filigree/core.py:47-47](/home/john/filigree/src/filigree/core.py:47) -- [/home/john/filigree/src/filigree/core.py:827-857](/home/john/filigree/src/filigree/core.py:827) -- [/home/john/filigree/src/filigree/db_workflow.py:90-127](/home/john/filigree/src/filigree/db_workflow.py:90) - -Evidence: `core.py` imports `WorkflowMixin`; `WorkflowMixin._seed_templates()` runtime-imports `_seed_builtin_packs` from `core.py`. - -Impact: The workflow layer reaches back into the composition root, making import timing and initialization refactors brittle. - -Remediation: - -- Move `_seed_builtin_packs` into a neutral module such as `template_bootstrap.py` or `db_templates.py`. -- Import that module from both `core.py` and `db_workflow.py`. - -### L-03: Plan Payload Validation Is Duplicated Across CLI, MCP, And DB Layers - -Locations: - -- [/home/john/filigree/src/filigree/cli_commands/planning.py:397-473](/home/john/filigree/src/filigree/cli_commands/planning.py:397) -- [/home/john/filigree/src/filigree/mcp_tools/planning.py:532-614](/home/john/filigree/src/filigree/mcp_tools/planning.py:532) -- [/home/john/filigree/src/filigree/db_planning.py:795-836](/home/john/filigree/src/filigree/db_planning.py:795) - -Evidence: Similar milestone/phase/step validation and error messages are implemented separately before both surfaces call `db.create_plan()`. - -Impact: Future plan fields or validation rules can drift by surface. - -Remediation: - -- Extract shared plan payload parsing/coercion returning normalized inputs plus surface-neutral validation errors. - -### L-04: Observation Line Numbering Permits Line 0 While Scanner Findings Are 1-Based - -Locations: - -- [/home/john/filigree/src/filigree/db_observations.py:255-270](/home/john/filigree/src/filigree/db_observations.py:255) -- [/home/john/filigree/src/filigree/db_files.py:856-872](/home/john/filigree/src/filigree/db_files.py:856) -- [/home/john/filigree/src/filigree/db_schema.py:261-266](/home/john/filigree/src/filigree/db_schema.py:261) - -Evidence: Observations accept `line >= 0`; scanner findings reject line numbers below 1. - -Impact: Manual observations can point to impossible source line 0, creating anchor drift between observation and scanner flows. - -Remediation: - -- Normalize observation line 0 to `None` or require `>= 1`. -- Migrate existing line-0 observations to `NULL`. -- Align MCP and CLI validation. - -### L-05: Project-Local Workflow Template Overrides Can Silently Weaken Policy - -Locations: - -- [/home/john/filigree/src/filigree/templates.py:823-834](/home/john/filigree/src/filigree/templates.py:823) -- [/home/john/filigree/src/filigree/templates.py:1118-1124](/home/john/filigree/src/filigree/templates.py:1118) -- [/home/john/filigree/src/filigree/templates.py:1193-1208](/home/john/filigree/src/filigree/templates.py:1193) - -Evidence: Project-local `.filigree/templates/*.json` load after built-ins and `_register_type(tpl)` overwrites existing type definitions. - -Impact: A project-local template can weaken workflow controls such as required close fields or transition policy. This may be intended extensibility, but policy changes are not prominent. - -Remediation: - -- Require explicit opt-in for overriding built-in types. -- Surface a warning in CLI/dashboard when a built-in type is overridden. -- Keep security-critical invariants in code rather than template-only policy. - -### L-06: Bundled Scanner TOML Writer Does Not Escape Strings - -Locations: - -- [/home/john/filigree/src/filigree/bundled_scanners.py:23-35](/home/john/filigree/src/filigree/bundled_scanners.py:23) - -Evidence: TOML is built with direct interpolation of names, descriptions, commands, args, and file types. - -Impact: A future bundled scanner containing quotes, backslashes, or control characters can generate invalid TOML. - -Remediation: - -- Use a TOML writer helper or a dedicated TOML string-quoting function. -- Add a fixture with quotes/backslashes/control characters. - -### L-07: XSS Guard Tests Are Brittle String-Snippet Checks - -Locations: - -- [/home/john/filigree/tests/static/test_xss_guards.py:14-43](/home/john/filigree/tests/static/test_xss_guards.py:14) -- [/home/john/filigree/src/filigree/static/js/views/detail.js:96-170](/home/john/filigree/src/filigree/static/js/views/detail.js:96) -- [/home/john/filigree/src/filigree/static/js/views/files.js:224-230](/home/john/filigree/src/filigree/static/js/views/files.js:224) -- [/home/john/filigree/src/filigree/static/js/app.js:487-511](/home/john/filigree/src/filigree/static/js/app.js:487) - -Evidence: The tests assert specific source strings such as `escHtml(...)` rather than executing rendering with adversarial input. - -Impact: A refactor can preserve the string but break runtime escaping, or remove the string while remaining safe. - -Remediation: - -- Add runtime DOM/rendering tests with malicious issue titles, statuses, file IDs, and transition names. -- Keep static string tests only as supplemental guardrails. - -### L-08: CLI Tests Mutate Process-Wide Current Working Directory - -Locations: - -- [/home/john/filigree/tests/cli/conftest.py:16-24](/home/john/filigree/tests/cli/conftest.py:16) - -Evidence: CLI fixtures change process-wide cwd for tests. - -Impact: Parallelization or fixture reuse can create order-sensitive test failures. - -Remediation: - -- Prefer isolated runner cwd parameters where possible. -- If global cwd mutation remains, mark affected tests and keep them serialized. - -### L-09: Loom Slim Issue Type Documentation Has Drifted - -Locations: - -- [/home/john/filigree/src/filigree/types/api.py:54-65](/home/john/filigree/src/filigree/types/api.py:54) -- [/home/john/filigree/src/filigree/generations/loom/types.py:30-39](/home/john/filigree/src/filigree/generations/loom/types.py:30) -- [/home/john/filigree/src/filigree/generations/loom/adapters.py:44-59](/home/john/filigree/src/filigree/generations/loom/adapters.py:44) - -Evidence: `SlimIssueLoom` documentation says its key difference from `SlimIssue` is `issue_id`, but `SlimIssue` already uses `issue_id`. - -Impact: Low-level documentation/type duplication can mislead future adapter changes. - -Remediation: - -- Reuse/alias the shared `SlimIssue` if the shape is identical. -- Otherwise update the comment to describe the actual distinction. - -## Suggested Remediation Order - -1. Fix trust-boundary bypasses first: H-01, H-02, M-01, M-03. -2. Fix MCP/CLI validation and degraded-startup behavior: H-03, H-04, M-10, M-11, M-12, M-13. -3. Fix scanner lifecycle correctness: H-05, H-06, M-20, M-28. -4. Fix planning graph integrity: H-07, H-08, M-16. -5. Address cross-domain consistency and architecture risks: H-09, M-04, M-05, M-06, M-22, M-23, M-24. -6. Harden tests and coverage around the above: M-25, M-26, M-27, L-07, L-08. - -## Verification Notes - -- This was a static, read-only audit. No tests were run because test execution can write caches, databases, temporary files, coverage data, or logs. -- Local coordinator checks used file reads only (`rg`, `sed`) plus the requested artifact write. -- Several concurrency findings are static race analyses and should be confirmed with fault-injection or parallel-writer tests during remediation. -- Exact line ranges are from the repository snapshot inspected on 2026-06-04 and may drift after edits. diff --git a/ROADMAP.md b/ROADMAP.md index 5ecaab0e..b8085918 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ Filigree is an issue tracker designed for AI coding agents. This roadmap outline ## Shipped (v1.6–v2.1) - **Autodiscovery MCP installs (v1.6)** -- Claude Code and Codex MCP configs use runtime project autodiscovery instead of project-pinned arguments or URL routing -- **Loom generation and 2.0 surface migration** -- stable `/api/loom/*` HTTP generation, MCP and CLI forward migration, composed `start_work` / `start_next_work`, and schema-mismatch UX across entry points +- **Weft generation and 2.0 surface migration** -- stable `/api/weft/*` HTTP generation, MCP and CLI forward migration, composed `start_work` / `start_next_work`, and schema-mismatch UX across entry points - **2.0.x stability releases** -- linked-worktree discovery, workflow-close semantics, HTTP force-close parity, and agent instruction refreshes - **2.1.0 release hardening** -- claim-aware concurrency guardrails, audit-event sequencing, explicit workflow `reverse_transitions`, safer HTTP force-close defaults, entity associations, and registry metadata migrations @@ -78,4 +78,4 @@ Filigree is an issue tracker designed for AI coding agents. This roadmap outline ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. Feature requests and bug reports are welcome via [GitHub Issues](https://github.com/tachyon-beep/filigree/issues). +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. Feature requests and bug reports are welcome via [GitHub Issues](https://github.com/foundryside-dev/filigree/issues). diff --git a/SECURITY.md b/SECURITY.md index f0dab163..f916e196 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ Only the latest release receives security fixes. ## Reporting a Vulnerability -To report a security vulnerability, use [GitHub's private vulnerability reporting](https://github.com/tachyon-beep/filigree/security/advisories/new). +To report a security vulnerability, use [GitHub's private vulnerability reporting](https://github.com/foundryside-dev/filigree/security/advisories/new). We aim to acknowledge reports within 7 days and provide a fix or mitigation plan within 30 days. diff --git a/docs/MIGRATION-3.0.md b/docs/MIGRATION-3.0.md new file mode 100644 index 00000000..e365adf9 --- /dev/null +++ b/docs/MIGRATION-3.0.md @@ -0,0 +1,447 @@ +# Consumer Migration Guide: 2.x → 3.0.0 + +Filigree 3.0.0 is a **major release**. It opens a SemVer-major boundary to land +the breaking wire-surface changes that could not ship mid-2.x without breaking +federation consumers. This guide is the single old→new reference for everyone +who consumes a Filigree surface — federation siblings (Loomweave, Wardline, +Legis), agents and scripts that bind MCP tool names, and out-of-suite consumers +pinned to the public HTTP endpoints. + +> **Operators** upgrading an installed Filigree (stop-writers, schema migration, +> store move) should read [UPGRADING.md](UPGRADING.md). This document is the +> *consumer*-facing contract reference. **Beads → Filigree** import is +> [MIGRATION.md](MIGRATION.md). + +## The five breaking surfaces + +| # | Surface | Who is affected | +|---|---------|-----------------| +| 1 | [MCP tool-name namespacing](#1-mcp-tool-name-namespacing) | Any caller that hardcodes MCP tool names | +| 2 | [`get_stats` alias keys removed](#2-get_stats-alias-keys-removed) | Anything reading project-stats JSON | +| 3 | [Loomweave / Weft rebrand](#3-loomweave-weft-rebrand) | Federation consumers of the HTTP / token / entity-binding surfaces | +| 4 | [`TransitionMode` enum](#4-transitionmode-enum-internal-python-api) | Embedders of the in-process Python API | +| 5 | [`safe_message` parity for claim/transition errors](#5-safe_message-parity-for-claimtransition-errors) | Consumers that string-match error *prose* over HTTP/MCP | + +Two schema migrations (v26 rebrand, v27 entity-association signing column) apply +**automatically and in place** on the first database open after the binary is +upgraded — see [UPGRADING.md](UPGRADING.md) and +[SCHEMA_MIGRATIONS.md](SCHEMA_MIGRATIONS.md). No consumer action is needed for +the migration itself; the items below are about the *wire* and *API* contracts +the migration exposes. + +--- + +## 1. MCP tool-name namespacing + +3.0.0 completes the namespacing started in 2.3.0 +([ADR-016](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-016-mcp-tool-namespacing.md)). +The ~115 flat tool names (`get_issue`, `list_findings`, `start_work`, …) were +renamed to a subsystem-namespaced `_` convention (`issue_get`, +`finding_list`, `work_start`, …). + +- **2.3.0** served the new names while still *resolving* the old ones (a + transition window). +- **3.0.0 removes the fallback.** `call_tool` now rejects a legacy flat name with + the standard `NOT_FOUND` (`Unknown tool`) envelope — exactly as a typo would. + +There is **no `filigree_` prefix** on the new names: MCP clients already surface +every tool as `mcp__filigree__`, so the server token is the client +wrapper's job. + +### What you must do + +- **Callers that hardcode tool names by string must switch to the new names** — + see the full table below. +- **Callers that read `list_tools` dynamically need no change** — it has served + only the namespaced names since 2.3.0. +- **The CLI is unaffected** — CLI verbs (`start-next-work`, `close`, …) are a + separate surface and were never renamed. +- The 2.3.0 deprecation-telemetry field (`deprecated_tool_name_calls` in + `mcp_status_get` / `mcp-status`) is **removed** — there is no longer a + deprecated call to count. + +### Full old → new tool-name map + +> Source of truth: `src/filigree/mcp_tools/rename.py` (`_RENAME_MAP_DATA`, a +> frozen, CI-validated map with import-time injectivity and test-enforced total +> coverage). Regenerate this table from that module if it ever changes. + +**`issue`** + +| Old (removed) | New | +|---|---| +| `get_issue` | `issue_get` | +| `list_issues` | `issue_list` | +| `search_issues` | `issue_search` | +| `create_issue` | `issue_create` | +| `update_issue` | `issue_update` | +| `close_issue` | `issue_close` | +| `reopen_issue` | `issue_reopen` | +| `delete_issue` | `issue_delete` | +| `validate_issue` | `issue_validate` | +| `batch_close` | `issue_batch_close` | +| `batch_update` | `issue_batch_update` | +| `get_issue_files` | `issue_file_list` | +| `get_issue_events` | `issue_event_list` | +| `get_issue_annotations` | `issue_annotation_list` | +| `label_subtree` | `issue_subtree_label` | + +**`work`** (claim / lease lifecycle + ready/blocked queue) + +| Old (removed) | New | +|---|---| +| `get_ready` | `work_ready` | +| `get_blocked` | `work_blocked` | +| `get_stale_claims` | `work_stale_list` | +| `claim_issue` | `work_claim` | +| `claim_next` | `work_claim_next` | +| `reclaim_issue` | `work_reclaim` | +| `release_claim` | `work_release` | +| `release_my_claims` | `work_release_mine` | +| `heartbeat_work` | `work_heartbeat` | +| `start_work` | `work_start` | +| `start_next_work` | `work_start_next` | + +**`dependency`** + +| Old (removed) | New | +|---|---| +| `add_dependency` | `dependency_add` | +| `remove_dependency` | `dependency_remove` | +| `get_critical_path` | `dependency_critical_path` | + +**`plan`** + +| Old (removed) | New | +|---|---| +| `create_plan` | `plan_create` | +| `create_plan_from_file` | `plan_create_from_file` | +| `get_plan` | `plan_get` | +| `add_plan_step` | `plan_step_add` | +| `move_plan_step` | `plan_step_move` | +| `label_plan_tree` | `plan_label_tree` | +| `retarget_plan_dependency` | `plan_dependency_retarget` | + +**`label`** + +| Old (removed) | New | +|---|---| +| `add_label` | `label_add` | +| `remove_label` | `label_remove` | +| `batch_add_label` | `label_batch_add` | +| `batch_remove_label` | `label_batch_remove` | +| `list_labels` | `label_list` | +| `get_label_taxonomy` | `label_taxonomy_get` | + +**`comment`** + +| Old (removed) | New | +|---|---| +| `add_comment` | `comment_add` | +| `batch_add_comment` | `comment_batch_add` | +| `get_comments` | `comment_list` | + +**`file`** + +| Old (removed) | New | +|---|---| +| `register_file` | `file_register` | +| `get_file` | `file_get` | +| `list_files` | `file_list` | +| `delete_file_record` | `file_delete` | +| `add_file_association` | `file_association_add` | +| `get_file_annotations` | `file_annotation_list` | +| `get_file_timeline` | `file_timeline_get` | + +**`finding`** + +| Old (removed) | New | +|---|---| +| `report_finding` | `finding_report` | +| `list_findings` | `finding_list` | +| `get_finding` | `finding_get` | +| `update_finding` | `finding_update` | +| `batch_update_findings` | `finding_batch_update` | +| `dismiss_finding` | `finding_dismiss` | +| `promote_finding` | `finding_promote` | +| `promote_finding_and_attach_entity` | `finding_promote_and_attach_entity` | + +**`annotation`** + +| Old (removed) | New | +|---|---| +| `annotate_file` | `annotation_create` | +| `get_annotation` | `annotation_get` | +| `list_annotations` | `annotation_list` | +| `list_attention_annotations` | `annotation_attention_list` | +| `update_annotation` | `annotation_update` | +| `resolve_annotation` | `annotation_resolve` | +| `link_annotation` | `annotation_link` | +| `unlink_annotation` | `annotation_unlink` | +| `carry_forward_annotation` | `annotation_carry_forward` | +| `supersede_annotation` | `annotation_supersede` | +| `promote_annotation` | `annotation_promote` | + +**`observation`** + +| Old (removed) | New | +|---|---| +| `observe` | `observation_create` | +| `list_observations` | `observation_list` | +| `dismiss_observation` | `observation_dismiss` | +| `link_observation` | `observation_link` | +| `promote_observation` | `observation_promote` | +| `promote_observations_to_issue` | `observation_promote_to_issue` | +| `batch_dismiss_observations` | `observation_batch_dismiss` | +| `batch_link_observations` | `observation_batch_link` | +| `batch_promote_observations` | `observation_batch_promote` | + +**`entity`** (cross-product associations — see +[ADR-029](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-029-entity-association-opacity.md)) + +| Old (removed) | New | +|---|---| +| `add_entity_association` | `entity_association_add` | +| `remove_entity_association` | `entity_association_remove` | +| `list_entity_associations` | `entity_association_list` | +| `list_associations_by_entity` | `entity_association_list_by_entity` | + +**`scanner`** / **`scan`** + +| Old (removed) | New | +|---|---| +| `list_scanners` | `scanner_list` | +| `list_available_scanners` | `scanner_available_list` | +| `enable_scanner` | `scanner_enable` | +| `disable_scanner` | `scanner_disable` | +| `trigger_scan` | `scan_trigger` | +| `trigger_scan_batch` | `scan_trigger_batch` | +| `get_scan_status` | `scan_status_get` | +| `preview_scan` | `scan_preview` | + +**`template` / `type` / `pack` / `schema` / `change` / `prompt_pack`** + +| Old (removed) | New | +|---|---| +| `get_template` | `template_get` | +| `get_type_info` | `type_get` | +| `list_types` | `type_list` | +| `list_packs` | `pack_list` | +| `get_schema` | `schema_get` | +| `get_changes` | `change_list` | +| `list_prompt_packs` | `prompt_pack_list` | +| `list_reconciliation_debt` | `reconciliation_debt_list` | + +**`workflow`** + +| Old (removed) | New | +|---|---| +| `get_workflow_statuses` | `workflow_status_list` | +| `get_valid_transitions` | `workflow_transition_list` | +| `explain_status` | `workflow_status_explain` | +| `get_workflow_guide` | `workflow_guide_get` | + +**Analytics / status** + +| Old (removed) | New | +|---|---| +| `get_stats` | `stats_get` | +| `get_summary` | `summary_get` | +| `get_metrics` | `metrics_get` | +| `get_mcp_status` | `mcp_status_get` | +| `session_context` | `session_context_get` | + +**`admin`** + +| Old (removed) | New | +|---|---| +| `archive_closed` | `admin_archive_closed` | +| `compact_events` | `admin_compact_events` | +| `export_jsonl` | `admin_export_jsonl` | +| `import_jsonl` | `admin_import_jsonl` | +| `reload_templates` | `admin_reload_templates` | +| `restart_dashboard` | `admin_restart_dashboard` | +| `undo_last` | `admin_undo_last` | + +--- + +## 2. `get_stats` alias keys removed + +The deprecated `status_name_counts` / `status_category_counts` keys are gone. +They were always exact duplicates of `by_status` / `by_category` — deprecated in +2.1.0 and removed at this major boundary. The drop loses no data. + +They are removed from **every** surface that carries `get_stats` output: + +- the MCP `stats_get` tool, +- the MCP `summary_get` JSON envelope (under the nested `stats` object), +- the HTTP `GET /api/stats` projection, +- the `filigree stats --json` CLI output. + +### What you must do + +| Removed key | Read instead | +|---|---| +| `status_name_counts` | `by_status` — counts keyed by literal workflow status name (`open`, `in_progress`, …) | +| `status_category_counts` | `by_category` — template categories `open` / `wip` / `done` | + +The values are identical to what the removed keys carried, so this is a key-name +change only. No in-suite sibling read the removed keys (confirmed by full +call-site enumeration); the affected audience is any **out-of-suite** consumer +pinned to the public `GET /api/stats` endpoint. + +--- + +## 3. Loomweave / Weft rebrand + +3.0.0 lands the **Clarion → Loomweave** (sibling / registry / SEI authority) and +**Loom → Weft** (federation + named API generation) renames as a hard +wire-break, with **no compatibility aliases**. The v26 data migration rewrites +every stored identifier prefix in place. + +### 3a. HTTP endpoint prefix: `/api/loom/*` → `/api/weft/*` + +The federation HTTP generation moved from the `loom` prefix to `weft`. Every +federation endpoint changed prefix: + +| Old | New | +|---|---| +| `GET /api/loom/changes` | `GET /api/weft/changes` | +| `GET /api/loom/issues` | `GET /api/weft/issues` | +| `GET /api/loom/blocked` | `GET /api/weft/blocked` | +| `GET /api/loom/findings` | `GET /api/weft/findings` | +| `GET /api/loom/files/{file_id}/findings` | `GET /api/weft/files/{file_id}/findings` | +| `POST /api/loom/batch/close` | `POST /api/weft/batch/close` | +| …(every `/api/loom/*` route) | …(same path under `/api/weft/*`) | + +Federation consumers must repoint their base path. The `/api/scan-results` and +`/api/observations` ingest aliases are unchanged. + +### 3b. Entity-association identifier: `clarion_entity_id` → `loomweave_entity_id` + +The entity-association binding (see +[ADR-029](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-029-entity-association-opacity.md)) +carried the sibling's pre-rebrand name. What changed for consumers: + +- **Response field.** The row returned by `entity_association_list` / + `entity_association_list_by_entity` (and the HTTP + `GET /api/issue/{id}/entity-associations` and + `GET /api/entity-associations` responses) now carries the key + **`loomweave_entity_id`** where it was `clarion_entity_id`. +- **The request parameter is unchanged.** Both transports still take an opaque + **`entity_id`** on `add` / `remove` / reverse-lookup — that name did **not** + change. Filigree never parses it (ADR-029, Decision 1). + +### 3c. SEI prefix: `clarion:eid:` → `loomweave:eid:` + +Stored Stable Entity Identifiers were rewritten in place by the v26 migration — +across the entity-association column, the `deleted_issues` tombstone +`entity_ids` array, and the entity-association audit events. A consumer that +**persisted** SEIs handed to/from Filigree must expect the `loomweave:eid:` +prefix on read. A federation consumer already on `loomweave:eid:` reconciles +cleanly. + +### 3d. Finding rule-id prefix: `CLA-` → `LMWV-` + +Finding rule-ids minted under the old prefix were rewritten `CLA-` → `LMWV-` by +the same migration. + +### 3e. Token env var (outbound registry token): `CLARION_LOOM_TOKEN` → `WEFT_TOKEN` + +The outbound registry/federation bearer token env var is now **`WEFT_TOKEN`**. +`CLARION_LOOM_TOKEN` is **no longer read**. Deployments that talk to the registry +must export `WEFT_TOKEN`. + +> Do not confuse this with the **inbound** federation bearer that gates +> Filigree's own `/api/weft/*` + `/mcp` surface — that is `WEFT_FEDERATION_TOKEN` +> (a distinct token; see [UPGRADING.md](UPGRADING.md) and ADR-018). The +> deprecated `FILIGREE_*_API_TOKEN` aliases for the inbound token still resolve +> with a migration nudge; the **outbound** `CLARION_LOOM_TOKEN` does not. + +### 3f. `registry_backend` config value: `clarion` → `loomweave` + +A deployed config naming the registry backend `clarion` is migrated to +`loomweave` on load via a one-shot rename-on-load shim — no manual edit is +required, but new config should write `loomweave`. + +### What did **not** rename (do not "fix" these) + +Some Clarion/Loom-era names are intentionally **unchanged** in 3.0.0 because the +federation hub has not locked their successors. Treat them as stable; do not +migrate them: + +- The registry error codes **`CLARION_REGISTRY_VERSION_MISMATCH`** and + **`CLARION_OUT_OF_SYNC`** — still emitted under these names. +- The **`loom://`** URI scheme — unchanged. + +> A note on stored Legis signatures: rewriting the `loomweave:eid:` prefix +> invalidated any Legis signature computed over the old `entity_ids`. Those +> signatures are *stale-pending-reissue* until Legis re-signs. Filigree never +> verifies them, so **reads do not break**; this is a Legis-side reconcile, not a +> Filigree consumer action. + +--- + +## 4. `TransitionMode` enum (internal Python API) + +The internal transition-direction flag changed from a bare `backward: bool` to a +`TransitionMode{FORWARD, BACKWARD}` enum +([ADR-019](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-019-transition-mode-enum.md)). +This flag has **no MCP / CLI / HTTP / wire exposure** — only code that embeds the +in-process `FiligreeDB` Python API is affected. + +```python +from filigree.types.api import TransitionMode + +# before (2.x): +db.update_issue(issue_id, status="open", backward=True) +# after (3.0.0): +db.update_issue(issue_id, status="open", mode=TransitionMode.BACKWARD) +``` + +`InvalidTransitionError.backward` is now `InvalidTransitionError.mode`. There is +no `backward=` alias — it is a clean cut. Agents, CLI users, and federation +consumers see no difference. + +--- + +## 5. `safe_message` parity for claim/transition errors + +`ClaimConflictError` and `InvalidTransitionError` now follow the +`WrongProjectError` pattern on **untrusted surfaces**: over HTTP and MCP, the +error *string* is a fixed, generic `safe_message` instead of reflecting arbitrary +call-site text (which could carry issue IDs or actor names). + +- Claim conflict → `"Issue is claimed by a different assignee"` +- Invalid transition → `"Requested status transition is not allowed"` + +**The structured recovery data is retained**, so agents still self-correct: + +- claim conflicts keep `details.observed` / `details.expected` (the assignees); +- transition errors keep `current_status` / `type_name` / `to_state` (and + `valid_transitions` when computed) in the HTTP `details` payload and the MCP + `TransitionError` payload. + +The **CLI keeps the full rich `str(exc)`** operator message — it is the local +diagnostic surface and is unchanged. + +### What you must do + +**Almost certainly nothing.** This is not a breaking change unless you +**string-matched the prose** of these two error messages over HTTP/MCP. The +correct pattern — which the 2.0 envelope contract already directs — is to switch +on the structured `code` and read `details`, not to parse the human string. If +you did parse the prose, switch to `code` + `details`. + +--- + +## See also + +- [UPGRADING.md](UPGRADING.md) — operator upgrade steps (stop writers, schema migration, store move). +- [SCHEMA_MIGRATIONS.md](SCHEMA_MIGRATIONS.md) — the v26 / v27 migration records. +- [MCP Server reference](mcp.md) · [Python API reference](api-reference.md) · [Federation contracts](federation/contracts.md). +- ADRs (GitHub): + [ADR-016 MCP tool namespacing](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-016-mcp-tool-namespacing.md) · + [ADR-019 TransitionMode](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-019-transition-mode-enum.md) · + [ADR-020 transport-bound actor identity](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md) · + [ADR-029 entity-association opacity](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-029-entity-association-opacity.md). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index cf40d610..f746116c 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -3,7 +3,10 @@ Step-by-step run sheet for migrating a project from [beads](https://github.com/steveyegge/beads) (`bd`) to filigree. Designed for both AI agents and humans. For Filigree version upgrades, including the 2.0.x to 2.1.0 in-place upgrade, -see [UPGRADING.md](UPGRADING.md). +see [UPGRADING.md](UPGRADING.md). Consumers migrating across the 3.0.0 major +boundary (MCP tool-name namespacing, the Loomweave/Weft rebrand, `get_stats` +key removal) should read the +[3.0.0 consumer migration guide](MIGRATION-3.0.md). ## Background @@ -44,7 +47,7 @@ uv add filigree ### Option C: From source ```bash -git clone https://github.com/tachyon-beep/filigree.git +git clone https://github.com/foundryside-dev/filigree.git cd filigree uv sync ``` diff --git a/docs/README.md b/docs/README.md index ae586dd8..c73d79aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ New to filigree? Start here: Detailed documentation for every interface: 2. **[CLI Reference](cli.md)** — All CLI commands with full parameter tables. -3. **[MCP Server Reference](mcp.md)** — 114 tools for native AI agent interaction via Model Context Protocol. +3. **[MCP Server Reference](mcp.md)** — 116 tools for native AI agent interaction via Model Context Protocol. 4. **[Workflow Templates](workflows.md)** — 24 issue types across 9 packs: state machines, transitions, field schemas, and enforcement levels. 5. **[Python API Reference](api-reference.md)** — `FiligreeDB`, `Issue`, `TemplateRegistry` for programmatic use. diff --git a/docs/SCHEMA_MIGRATIONS.md b/docs/SCHEMA_MIGRATIONS.md index 646f88bf..985b8c90 100644 --- a/docs/SCHEMA_MIGRATIONS.md +++ b/docs/SCHEMA_MIGRATIONS.md @@ -31,6 +31,8 @@ without grepping source. The source of truth remains | Release | Ships `user_version` | Notes | |---------|----------------------|-------| +| 3.0.0 | 27 | Migration 26 to 27 (signature-bypass fix): nullable `entity_associations.signed_content_hash` (`TEXT`) records the content_hash the current `signature` was cut over (the Legis HMAC binds content_hash). Backfilled `= content_hash_at_attach WHERE signature IS NOT NULL` — sound because the pre-fix re-attach unconditionally NULLed `signature` on any signatureless refresh, so a row that still holds a signature has not drifted since signing. The closure gate now (a) classifies governed-ness by `signature IS NOT NULL` (not truthiness; a blank signature is normalised to `NULL` at the data layer), and (b) fails closed as `STALE` when `signed_content_hash != content_hash_at_attach` (the sign-off has drifted) without a network call. The re-attach UPSERT is now sticky: `signature`/`signoff_seq`/`signed_content_hash` update only on a write that carries a signature (only Legis signs), so a routine signatureless drift refresh no longer revokes governance. **Scope boundary:** this detects CONTENT drift, not IDENTITY drift — the 25→26 rebrand rewrote `entity_id` (changing the HMAC input) while leaving content_hash untouched, so those rows backfill as content-fresh, the gate consults Legis, and Legis resolves the identity drift by re-signing in lockstep. Additive + idempotent; round-tripped through `export`/`import`. Migration 25 to 26 (Loomweave/Weft rebrand data pass): rename `entity_associations.clarion_entity_id` -> `loomweave_entity_id` and rewrite stored `clarion:eid:` SEI prefixes -> `loomweave:eid:` and finding rule-id prefixes `CLA-` -> `LMWV-` in place (suffixes preserved), across the binding, the F5 deletion tombstone `entity_ids` array, and the audit `events` log. Stored Legis `signature`s become stale-pending-reissue (the HMAC was cut over the old entity_id); Filigree never verifies, Legis re-signs in lockstep. Migration 24 to 25 (B1, Legis governed-sign-off binding): nullable `entity_associations.signature` (`TEXT`) and `signoff_seq` (`INTEGER`). Opaque HMAC + sequence Legis sends when binding a cleared governed sign-off; Filigree stores them verbatim and never verifies them. Additive + idempotent; no backfill (`NULL` = no key configured / non-governed binding); round-tripped through `export`/`import`. Migration 23 to 24 (ADR-012, transport-bound actor identity): nullable `verified_*` column on the 5 runtime event-bearing tables (`events.verified_actor`, `file_events.verified_actor`, `annotation_events.verified_actor`, `comments.verified_author`, `observations.verified_actor`). Additive + idempotent; no backfill (`NULL` = no transport proof); the `events` dedup unique index is **not** extended | +| 2.3.0 | 23 | Migration 22 to 23: `entity_associations.entity_kind` caller-supplied metadata; public projections expose canonical `entity_id` with `clarion_entity_id` compatibility alias | | 2.1.1 | 21 | Migration 20 to 21: `deleted_issues.entity_ids`, surfaced as `affected_entities` on the `issue_deleted` deletion-signal record (F5 amplifier) | | 2.1.0 | 20 | Migrations 14 to 20: entity associations (v15), event sequencing (v16), file registry metadata (v17), `application_id` stamp (v18), scan-finding fingerprints (v19), and the `deleted_issues` tombstone (v20) | | 2.0.0 to 2.0.3 | 14 | Loom/API generation and 2.0 surface releases | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 6af18ba2..b9e2bbff 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -1,7 +1,168 @@ # Upgrading Filigree This guide covers version-to-version Filigree upgrades. For Beads import, see -[MIGRATION.md](MIGRATION.md). +[MIGRATION.md](MIGRATION.md). For the consumer-facing old→new contract reference +(MCP tool names, stats keys, the Loomweave/Weft rebrand surfaces), see the +[3.0.0 consumer migration guide](MIGRATION-3.0.md). + +## Upgrading to 3.0.0 (store consolidation) + +Filigree 3.0.0 moves the machine-owned store from the legacy `.filigree/` +directory to the federation convention `.weft/filigree/` (the `.filigree.conf` +anchor stays). The move happens **only** on an explicit `filigree init` against a +legacy install — never on passive discovery — and is crash-convergent: it copies +the database forward, rewrites the conf, then removes the legacy database. A +re-run resumes a half-finished move. + +### Stop ALL writers before upgrading — this is mandatory, not advisory + +Because the migration **deletes the legacy database** once it has been copied +forward, any process still holding the legacy database open can lose writes: a +write it commits after the copy lands on a file that is about to be unlinked, and +is never carried into the new store. Before running the `filigree init` that +performs the migration, stop **every** writer for the project: + +- the web dashboard (ephemeral session dashboards and `--server-mode` daemons — + `filigree server stop`), +- any MCP server holding the project open, +- other CLI/agent sessions. + +Filigree defends this automatically where it can: `migrate` **refuses** (with a +`StoreMigrationBusyError` naming the port) when it detects a registered +server-mode daemon or a bound ephemeral dashboard for the project, and it holds +the legacy database's write lock across the copy so an actively-writing process +is blocked rather than silently dropped. **Known limitation:** an MCP/stdio +connection opened *in your own session* (for example, the agent session running +the upgrade) is not registered anywhere and cannot be detected — you must stop it +yourself. When in doubt, quiesce everything and re-run; the migration is +idempotent and safe to repeat. + +After the migration completes, restart the dashboard / server / MCP processes so +they reopen against `.weft/filigree/`. A daemon left running from before the move +keeps writing to its now-stale connection until it is restarted. + +## Upgrading to 3.0.0 (MCP tool-name namespacing) + +3.0.0 completes the MCP tool-name namespacing started in 2.3.0 (ADR-016). The +~115 flat tool names (`get_issue`, `list_findings`, `start_work`, …) were +renamed to a subsystem-namespaced `_` convention (`issue_get`, +`finding_list`, `work_start`, …) so an agent — and the tool-search ranker — can +disambiguate the catalogue by entity prefix. + +**2.3.0 served the new names while still accepting the old ones** (a transition +window: `list_tools` advertised only the 116 namespaced names, but `call_tool` +resolved a legacy name to the same handler). **3.0.0 removes that fallback.** A +call to a legacy flat name now returns the standard `NOT_FOUND` (`Unknown tool`) +envelope, exactly as a typo would. There is no `filigree_` prefix on the new +names: MCP clients already surface every tool as `mcp__filigree__`, so the +server token is supplied by the client wrapper. + +### What you must do + +- **MCP consumers (federation siblings, agents, scripts) that bind tool names by + string must switch to the new names.** Any caller that reads `list_tools` + dynamically already sees only the new names and needs no change — only + hardcoded old names break. The full mapping is below. +- The **CLI is unaffected** — CLI verbs (`start-next-work`, `close`, …) are a + separate surface and were never renamed. +- The deprecation-telemetry signal that 2.3.0 surfaced in `get_mcp_status` + (`deprecated_tool_name_calls`) is **removed** — there is no longer a deprecated + call to count. + +### Full old → new tool-name mapping + +The complete ~115-row old→new table (grouped by subsystem) is the +[3.0.0 consumer migration guide §1](MIGRATION-3.0.md#1-mcp-tool-name-namespacing). +A few of the most-bound renames, to orient: + +| Old name (removed) | New name | +| --- | --- | +| `get_issue` | `issue_get` | +| `list_issues` | `issue_list` | +| `start_work` | `work_start` | +| `start_next_work` | `work_start_next` | +| `get_ready` | `work_ready` | +| `list_findings` | `finding_list` | +| `report_finding` | `finding_report` | +| `get_stats` | `stats_get` | +| `session_context` | `session_context_get` | + +The pattern is `_` → `_` (`get_issue` → `issue_get`), +with batch/list/get verbs trailing (`batch_close` → `issue_batch_close`). See the +guide for every row. + +## Upgrading to 3.0.0 (Loomweave / Weft rebrand) + +3.0.0 lands the **Clarion → Loomweave** and **Loom → Weft** renames as a hard +wire-break (schema v26), **with no compatibility aliases**. The v26 data +migration rewrites every stored identifier prefix in place — it runs +automatically on the first database open after the binary is upgraded, alongside +the v27 entity-association signing-column add. + +The consumer-visible contract changes are enumerated in the +[3.0.0 consumer migration guide §3](MIGRATION-3.0.md#3-loomweave-weft-rebrand). +In brief: + +- HTTP federation prefix `/api/loom/*` → `/api/weft/*`. +- Entity-association response field `clarion_entity_id` → `loomweave_entity_id` + (the opaque request parameter `entity_id` is unchanged). +- Stored SEI prefix `clarion:eid:` → `loomweave:eid:`; finding rule-ids + `CLA-` → `LMWV-`. +- Outbound registry token env var `CLARION_LOOM_TOKEN` → `WEFT_TOKEN` (distinct + from the inbound `WEFT_FEDERATION_TOKEN` that gates this server's + `/api/weft/*` + `/mcp` surface). +- `registry_backend` config value `clarion` → `loomweave` (migrated on load). + +**Not renamed in 3.0.0** (intentionally — do not migrate these): the registry +error codes `CLARION_REGISTRY_VERSION_MISMATCH` / `CLARION_OUT_OF_SYNC` and the +`loom://` URI scheme. + +### What you must do + +- Repoint federation consumers from `/api/loom/*` to `/api/weft/*`. +- Export `WEFT_TOKEN` where a deployment previously set `CLARION_LOOM_TOKEN`. +- No manual database or config edit is required — the v26 migration and the + config rename-on-load shim handle the stored data. + +## Upgrading to 3.0.0 (TransitionMode enum — internal Python API) + +The internal transition-direction flag `backward: bool` is replaced by a +`TransitionMode{FORWARD, BACKWARD}` enum +([ADR-019](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-019-transition-mode-enum.md)). +This flag has **no MCP / CLI / HTTP / wire exposure** — only code that embeds the +in-process `FiligreeDB` Python API is affected. Migrate +`update_issue(..., backward=True)` to `mode=TransitionMode.BACKWARD` (imported +from `filigree.types.api`); `InvalidTransitionError.backward` is now `.mode`. +There is no `backward=` alias. See the +[consumer migration guide §4](MIGRATION-3.0.md#4-transitionmode-enum-internal-python-api). + +## Upgrading to 3.0.0 (get_stats alias keys removed) + +The deprecated `status_name_counts` / `status_category_counts` keys are gone +from the project-stats payload. They were always exact duplicates of +`by_status` / `by_category` respectively — deprecated in 2.1.0 and removed at +this major boundary. + +The keys are dropped from **every** surface that carries `get_stats` output: + +- the MCP `stats_get` tool, +- the MCP `summary_get` JSON envelope (under the nested `stats` object), +- the HTTP `GET /api/stats` projection, +- the `filigree stats --json` CLI output. + +### What you must do + +If you read either removed key, switch to the canonical pair: + +| Removed key | Read instead | +| --- | --- | +| `status_name_counts` | `by_status` (counts keyed by literal workflow status name, e.g. `open`, `in_progress`) | +| `status_category_counts` | `by_category` (template categories `open` / `wip` / `done`) | + +The values are identical to what the removed keys carried, so this is a +key-name change only. No in-suite sibling read the removed keys; the affected +audience is any **out-of-suite** consumer pinned to the public `GET /api/stats` +endpoint. ## Upgrading from 2.1.0 to 2.1.1 @@ -14,9 +175,11 @@ first 2.1.1 open applies a single in-place migration: The migration is an additive `ALTER TABLE ... ADD COLUMN` (`NOT NULL DEFAULT '[]'`) that backfills existing tombstones; `FiligreeDB.initialize()` applies it -automatically on first open, or run `filigree doctor --fix` for an -operator-controlled upgrade. No application-level action is required. A -federation consumer of `/api/loom/changes` should begin honouring the new +automatically on first normal database open after the binary is upgraded. Use +`filigree doctor` before and after the upgrade to validate local configuration; +`doctor --fix` is limited to local binding and dashboard-pointer repair. No application-level action is required. A +federation consumer of `/api/weft/changes` (the `/api/loom/changes` endpoint as +of 2.1.1; renamed to `/api/weft/*` in 3.0.0) should begin honouring the new `affected_entities` field on `issue_deleted` records — purge the listed entity bindings on reconcile; see `docs/federation/contracts.md` §F5. @@ -35,9 +198,10 @@ Filigree 2.1.0 ships database schema `user_version` 20. Databases from the | 18 to 19 | v19 | Adds `scan_findings.fingerprint` and partitions the dedup index | | 19 to 20 | v20 | Adds the `deleted_issues` tombstone behind the `issue_deleted` changes-feed signal | -`FiligreeDB.initialize()` applies pending migrations automatically. For an -operator-controlled upgrade, use `filigree doctor --fix` so the schema step is -visible in the terminal and uses the database declared by `.filigree.conf`. +`FiligreeDB.initialize()` applies pending migrations automatically on the first +normal database open after the binary is upgraded. `filigree doctor` validates +the configured database path and reports schema state; `doctor --fix` does not +apply schema migrations. ### Before You Upgrade @@ -65,7 +229,7 @@ Run these commands from each project root: ```bash filigree doctor -filigree doctor --fix +filigree stats filigree doctor filigree stats filigree session-context @@ -75,17 +239,27 @@ For source checkouts, prefix the same commands with `uv run`: ```bash uv run filigree doctor -uv run filigree doctor --fix +uv run filigree stats uv run filigree doctor uv run filigree stats uv run filigree session-context ``` -`doctor --fix` is the supported in-place upgrader. It opens the existing -database, applies pending schema migrations, refreshes generated context, and -repairs install metadata where possible. Do not run `filigree init`, edit -`PRAGMA user_version` by hand, or delete and recreate `.filigree/` to upgrade -an existing project. +The first normal DB command after the binary upgrade opens the existing +database and applies pending schema migrations through the standard +`FiligreeDB.initialize()` path. Do not edit `PRAGMA user_version` by hand or +delete and recreate `.filigree/` to upgrade an existing project. + +Automation can use `filigree doctor --fix --json` for the shared doctor summary +contract when it wants local binding/dashboard-pointer repair: + +```json +{"ok": true, "checks": [{"id": "mcp.registration", "status": "fixed", "fixed": true}], "next_actions": []} +``` + +`--fix` repairs only local agent bindings and stale dashboard pointers. It does +not mutate issue rows, scan findings, scanner results, entity associations, or +database schema. An automation wrapper should do only the safe orchestration around this built-in path: @@ -95,7 +269,7 @@ path: stop_filigree_writers backup_configured_database upgrade_filigree_binary_to_2_1_0 -filigree doctor --fix +filigree stats filigree doctor restart_mcp_or_dashboard_processes ``` diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 29ba96d1..90b13930 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -43,7 +43,7 @@ All MCP tools and CLI `--json` output use the unified 2.0 envelopes: - **Batch ops** return `{succeeded: [...], failed: [{id, error, code}, ...], newly_unblocked?: [...]}`. `failed` is always present (empty list if none); `newly_unblocked` is present only when non-empty (omitted when the op unblocked nothing). Pass `response_detail="full"` (MCP) or `--detail=full` (CLI) to get full records back instead of slim summaries. - **List ops** return `{items: [...], has_more: bool, next_offset?: int}`. `has_more` is always present; `next_offset` appears only when there is a next page. -- **Errors** return `{error: str, code: ErrorCode, details?: dict}` where `code` is one of: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`, `INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, `CLARION_REGISTRY_VERSION_MISMATCH`, `BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. +- **Errors** return `{error: str, code: ErrorCode, details?: dict}` where `code` is one of: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`, `INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`, `LOOMWEAVE_REGISTRY_VERSION_MISMATCH`, `BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`. The issue ID is always exposed as `issue_id` (in MCP inputs, response payloads, and CLI JSON). Status is always `status`; "state" was retired as a user-facing word in 2.0. diff --git a/docs/api-reference.md b/docs/api-reference.md index f4afde64..10fb368c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -835,8 +835,6 @@ Returns project statistics: { "by_status": {"open": 5, "in_progress": 2, ...}, "by_category": {"open": 5, "wip": 2, "done": 10}, - "status_name_counts": {"open": 5, "in_progress": 2, ...}, - "status_category_counts": {"open": 5, "wip": 2, "done": 10}, "by_type": {"task": 8, "bug": 4, ...}, "ready_count": int, "blocked_count": int, @@ -845,11 +843,9 @@ Returns project statistics: ``` `by_status` holds counts keyed by literal workflow status name; `by_category` -holds template-aware category counts (`open`/`wip`/`done`). `status_name_counts` -and `status_category_counts` are **deprecated** exact duplicates of `by_status` -and `by_category` respectively (filigree-17694d2db8), retained as compatibility -aliases per ADR-009 §7 and scheduled for removal in the next major. Read -`by_status` / `by_category`. +holds template-aware category counts (`open`/`wip`/`done`). The deprecated +`status_name_counts` / `status_category_counts` aliases (exact duplicates of +`by_status` / `by_category`) were **removed in 3.0.0** (filigree-e4181ae767). #### `get_recent_events` @@ -1327,6 +1323,40 @@ class HardEnforcementError(ValueError): missing_fields: list[str] ``` +### TransitionMode (3.0.0) + +```python +from filigree.types.api import TransitionMode + +class TransitionMode(Enum): + FORWARD = "forward" + BACKWARD = "backward" +``` + +3.0.0 replaced the internal `backward: bool` selector on `update_issue` / +`validate_transition` (and the `DBMixinProtocol`) with this enum +([ADR-019](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-019-transition-mode-enum.md)). +A reverse/escape transition is now selected with `mode=TransitionMode.BACKWARD` +(was `backward=True`); the default `TransitionMode.FORWARD` selects normal +forward validation. The flag has no MCP/CLI/HTTP exposure, so there is no +`backward=` compatibility alias — embedders of the Python API must migrate the +keyword. `InvalidTransitionError.backward` is likewise now +`InvalidTransitionError.mode`. + +### InvalidTransitionError / ClaimConflictError safe messages (3.0.0) + +`InvalidTransitionError` and `ClaimConflictError` (both `ValueError` subclasses, +`filigree.types.api`) gained a `safe_message` property in 3.0.0, mirroring the +`WrongProjectError` pattern. On the **untrusted** HTTP / MCP surfaces the error +*string* is now the fixed, ID/actor-free `safe_message` (`"Requested status +transition is not allowed"` / `"Issue is claimed by a different assignee"`) +rather than the full `str(exc)`. The structured recovery data is **retained** in +the wire `details` payload (transition: `current_status` / `type_name` / +`to_state` / `valid_transitions`; claim: `observed` / `expected`), so agents +still self-correct. The in-process Python API and the CLI keep the full rich +`str(exc)`. See the +[3.0.0 consumer migration guide §5](MIGRATION-3.0.md#5-safe_message-parity-for-claimtransition-errors). + --- ## Module Functions diff --git a/docs/architecture.md b/docs/architecture.md index de9992e2..3d94364e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,7 +141,7 @@ CREATE TABLE events ( ); ``` -Indexed on `issue_id`, `created_at`, and a composite index on `(issue_id, created_at DESC)` for efficient per-issue history queries. Powers event history, undo, session resumption, and analytics. Per [ADR-003](https://github.com/tachyon-beep/filigree/blob/main/docs/architecture/decisions/ADR-003-operational-durability-not-audit-proofing.md), these records are durable for operational utility rather than audit-proof evidence. +Indexed on `issue_id`, `created_at`, and a composite index on `(issue_id, created_at DESC)` for efficient per-issue history queries. Powers event history, undo, session resumption, and analytics. Per [ADR-003](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-003-operational-durability-not-audit-proofing.md), these records are durable for operational utility rather than audit-proof evidence. #### `comments` @@ -239,7 +239,7 @@ Every mutation creates an event record. This enables: - **Analytics** — cycle time, lead time, and throughput computed from events - **Archival** — old events can be compacted without losing issue state -See [ADR-003](https://github.com/tachyon-beep/filigree/blob/main/docs/architecture/decisions/ADR-003-operational-durability-not-audit-proofing.md): +See [ADR-003](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-003-operational-durability-not-audit-proofing.md): Filigree records are durable working memory, not audit-proof evidence. ### Batch Optimizations diff --git a/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md b/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md index ef74c4e3..44996fea 100644 --- a/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md +++ b/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md @@ -5,6 +5,17 @@ **Deciders**: John (project lead) **Context**: Filigree 2.0 direction; Loom federation participation (Clarion, Wardline, Shuttle); first external consumer (Clarion) approaching integration at WP2/9. +> **Roster pointer (added 2026-06-05).** This ADR's references to the Loom +> roster as "Clarion, Wardline, Shuttle, filigree" are **stale** and are +> retained here only as the historical context of the 2026-04-24 decision. The +> **authoritative federation roster and posture now live at `~/loom/doctrine.md`**: +> 5 realized members (Clarion, Filigree, Wardline, Legis, Charter); **Shuttle is +> a roadmap thought-bubble with no repo, not a fourth member**. Where this ADR +> names "Clarion / Wardline / Shuttle" as the peer set, read the loom doctrine +> for the current membership. **The decision below — Filigree's own named HTTP +> generations (`classic`, `loom`) and their lifecycle — is Filigree-owned and +> unchanged by this note.** + ## Summary 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 introduces **named API generations** at the HTTP surface — `classic` (historical, at `/api/*` — mostly un-prefixed, with one `/api/v1/` outlier, `POST /api/v1/scan-results`) and `loom` (new, at `/api/loom/*`) — with **lifecycles decoupled from filigree's code-version cadence**. MCP and CLI reflect the living / current-recommended surface only; HTTP is where pinned generations live. The federation posture is **cooperation, not mandate**: every `loom`-generation endpoint must be fully functional in the absence of other federation components. @@ -226,4 +237,4 @@ See the 2.0 federation work package (`docs/plans/2026-04-24-2.0-federation-work- - **Snapshot**: `docs/plans/2026-04-21-2.0-unified-surface-snapshot.md` (historical; still accurate for pre-2026-04-24 state) - **2.0 work package**: `docs/plans/2026-04-24-2.0-federation-work-package.md` - **Clarion ADRs consulted**: ADR-004 (finding-exchange-format), ADR-014 (filigree-registry-backend), ADR-015 (wardline-filigree-emission), ADR-016 (observation-transport), ADR-017 (severity-and-dedup) — all in `/home/john/clarion/docs/clarion/adr/`. -- **Loom doctrine**: `/home/john/clarion/docs/suite/loom.md` (federation framing, cross-product contracts) +- **Loom doctrine**: `~/loom/doctrine.md` (authoritative federation framing, roster, and axiom — promoted here 2026-06-05 from the former `/home/john/clarion/docs/suite/loom.md`, which is now a pointer). Cross-product contract index: `~/loom/contracts-index.md`. diff --git a/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md b/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md index 759dbb1b..e1097c29 100644 --- a/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md +++ b/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md @@ -117,6 +117,69 @@ We adopt an explicit threat model for actor strings in Filigree 2.x: - This ADR does not change `sanitize_actor`. It documents the existing semantics and pins them with tests. +## v24 increment — verified actor lands (schema v24, 3.0.0) + +This is the full lift of the **verified-actor** half of the §5 deferral that +the "Partial lift (2026-06-03)" note anticipated. ADR-018 had already landed +the access-gate half (opt-in bearer-token enforcement on the loom surface); +schema v24 now binds the transport's proven identity into the audit trail for +the two surfaces whose transport boundary is unambiguous (CLI, MCP stdio), +while the remaining surfaces stay explicitly deferred. + +### What landed + +- **Schema (v24).** A nullable `verified_*` column is added to every runtime + event-bearing table: `events.verified_actor`, `file_events.verified_actor`, + `annotation_events.verified_actor`, `comments.verified_author`, + `observations.verified_actor`. The claimed `actor`/`author` value is + unchanged. `verified_*` holds the transport-verified identity (the OS user + the writing process ran as) or `NULL` when no transport proof exists — the + value for every historical row (no backfill), every unverified surface, and + every system/cascade/migration-authored write. The `events` dedup unique + index is **not** extended: `verified_actor` is attribution metadata, not part + of event identity. Migration `migrate_v23_to_v24` is additive and idempotent. + +- **Session-level plumbing.** The verified identity is resolved once at the + process entry point and held on the session (`FiligreeDB._verified_actor`, + `str | None`, set via `set_verified_actor()`); it propagates to worker-thread + clones via `copy.copy`. Resolution is `actor_identity.resolve_os_actor()` + (POSIX `pwd`), which returns `None` on Windows or any failure and never + raises. + +- **Entry-point resolvers.** The CLI (`get_db()`) and the MCP stdio startup + path (`_attempt_startup`) set the verified actor. These are the surfaces + whose transport boundary directly identifies an OS user. + +- **Conflict policy: record both, warn, never block.** Both the claimed and + verified identities are persisted. On mismatch a non-blocking `ACTOR_MISMATCH` + warning is surfaced; the write always proceeds. Placeholder framework default + claims (`cli`, `mcp`) are suppressed — they are not a real disagreement, so + they raise no warning. Two surfaces carry the warning: the CLI emits it on + **stderr** (always, so production stderr never pollutes `--json` stdout); + MCP injects a top-level `warnings` array into the tool-response envelope + (`_inject_warnings`). The MCP `add_comment` result also exposes + `verified_author` via `comment_to_mcp`. + +- **Backup/restore preserves, does not re-stamp.** `export_jsonl` carries + `verified_*` (it is a `SELECT *`); `import_jsonl` preserves the stored value + on restore (`record.get`) for all five tables. Restore reproduces the + original verified identity rather than re-stamping it with the importer's. + +### Explicitly out of scope (still deferred) + +- **MCP-HTTP peer identity.** The MCP-over-HTTP transport does not yet bind a + proven peer identity into `verified_*`. Only MCP stdio is covered. +- **HTTP dashboard authentication.** The dashboard remains unauthenticated; + binding a verified actor there is still future work (the access-gate half is + ADR-018's bearer token; the verified-actor half here does not extend to it). +- **The Loom federation wire shape.** `CommentRecordLoom` and the + `/api/loom/...` projections deliberately do **not** carry `verified_*`. The + federation contract is held stable via an explicit adapter; verified identity + is a local-audit concern, not part of the cross-product wire shape. This is a + decision, not an omission — re-opening it requires a federation-contract + revision, governed by the same cross-host trigger in the Negative + consequences above. + ## Related - [ADR-008](ADR-008-claim-aware-write-defaults.md) — claim-aware write defaults diff --git a/docs/architecture/decisions/ADR-013-backward-edges-in-workflow-templates.md b/docs/architecture/decisions/ADR-013-backward-edges-in-workflow-templates.md index 947e3afa..d9ca533c 100644 --- a/docs/architecture/decisions/ADR-013-backward-edges-in-workflow-templates.md +++ b/docs/architecture/decisions/ADR-013-backward-edges-in-workflow-templates.md @@ -73,8 +73,9 @@ but the edge is still declared and audited. - **Refines**: [ADR-005: Workflow Enforcement and Explicit Cleanup Paths](./ADR-005-workflow-enforcement-and-cleanup-paths.md) - **Related to**: [ADR-003: Operational Durability, Not Audit-Proof Records](./ADR-003-operational-durability-not-audit-proofing.md) +- **Refined by**: [ADR-019: TransitionMode Enum](./ADR-019-transition-mode-enum.md) — the `backward=True` opt-in this ADR defined is, as of 3.0.0, `mode=TransitionMode.BACKWARD`. The reverse-lane semantics are unchanged. ## References -- `docs/plans/2026-05-18-2.1.0-release-prep.md` +- `docs/plans/completed/2026-05-18-2.1.0-release-prep.md` - `docs/plans/2026-05-17-2.1-db-issues-hardening-design.md` diff --git a/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md b/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md index dc0e1014..1f34f633 100644 --- a/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md +++ b/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md @@ -7,6 +7,25 @@ Filigree↔Clarion — token sent, ignored). Implements **option (b)** from that issue, chosen by the project lead during the 2026-06-03 API review. +> **Amendment (3.0.0, 2026-06-07):** the env var was renamed to +> **`WEFT_FEDERATION_TOKEN`** (federation plumbing takes the Weft prefix). The +> original `FILIGREE_API_TOKEN` and the interim `FILIGREE_FEDERATION_API_TOKEN` +> are still read as deprecated, backward-compatible fallbacks (removal post-1.0). +> Read order: `WEFT_FEDERATION_TOKEN` → `FILIGREE_FEDERATION_API_TOKEN` → +> `FILIGREE_API_TOKEN`. References to `FILIGREE_API_TOKEN` below are historical. +> +> **Amendment (3.0.0, PR #52 B1):** the "classic federation-write aliases stay +> open" boundary recorded below (originally only `/api/v1/scan-results`) is +> **superseded**. The classic versioned ingest aliases — `/api/v1/scan-results` +> **and** `/api/v1/observations` — are now gated alongside their living/weft +> siblings via `CLASSIC_FEDERATION_ALIASES`, because they dispatch the same +> federation-write handlers and an ungated sibling was a deconfliction gap (a +> federation producer could write the project DB without the token its +> siblings require). The *human/legacy classic surface* (`/api/issue/…`, +> `/api/issues`, the dashboard UI) stays open as before — only the classic +> **federation-write** aliases moved behind the gate. The "Negative / known +> boundaries" note about `/api/v1/scan-results` being unenforced is historical. + ## Summary Filigree's HTTP API gains **opt-in** inbound authentication for the **loom diff --git a/docs/architecture/decisions/ADR-019-transition-mode-enum.md b/docs/architecture/decisions/ADR-019-transition-mode-enum.md new file mode 100644 index 00000000..1ec63248 --- /dev/null +++ b/docs/architecture/decisions/ADR-019-transition-mode-enum.md @@ -0,0 +1,133 @@ +# ADR-019: `TransitionMode` Enum Replaces the Internal `backward` Boolean + +**Status**: Accepted +**Date**: 2026-06-08 +**Deciders**: John (project lead) +**Context**: 3.0.0 breaking-bundle (`filigree-9b4bb6e52e`). The major-version boundary is the cheap window to replace an internal, wire-invisible flag with a self-documenting type. + +## Summary + +The transition-direction flag that distinguished a *forward* status change from +a *reverse/escape* one was a bare `backward: bool`, threaded across +`TemplateRegistry.validate_transition`, `update_issue`, the `DBMixinProtocol` +signature, and `InvalidTransitionError`. 3.0.0 replaces it with a +`TransitionMode{FORWARD, BACKWARD}` enum in `filigree.types.api`. The flag has +**no MCP / CLI / HTTP / wire exposure**, so it is replaced outright with no +compatibility alias; the only affected callers are embedders of the internal +Python API. + +## Context + +ADR-013 introduced declared reverse/escape transitions (`reverse_transitions`) +and required callers to opt into the escape lane with `backward=True`. The +resulting flag had two problems: + +- **Opaque call sites.** `update_issue(..., backward=True)` and the bare `True` / + `False` passed through nine internal call sites in `db_issues.py` and + `templates.py` carried no hint of what the boolean *meant*. A reader had to + trace the parameter to learn that `True` selects the `reverse_transitions` + validation lane. +- **A boolean is not the right shape for a closed set of directions.** Direction + is a two-valued *category*, not a yes/no answer to a question. A boolean + invites "what does `False` mean here?" at every site and gives no anchor for a + third mode should one ever be needed. + +Critically, the flag is **internal only**. A call-site enumeration +(`filigree-9b4bb6e52e`) confirmed it appears on no MCP tool input, no CLI flag, +and no HTTP request body — it is reachable only from `FiligreeDB` Python methods +and the template registry. That removes the usual major-version constraint +(serve both names through a deprecation window): there is no external consumer to +migrate, so the rename can be a clean cut. + +## Decision + +We will define a `TransitionMode` enum and replace every `backward: bool` +parameter and read with it: + +```python +class TransitionMode(Enum): + """Direction of a status transition. Replaces the historical `backward` bool.""" + FORWARD = "forward" + BACKWARD = "backward" +``` + +- `validate_transition`, `update_issue`, and `DBMixinProtocol.update_issue` take + `mode: TransitionMode = TransitionMode.FORWARD` instead of + `backward: bool = False`. +- `InvalidTransitionError.backward` becomes `InvalidTransitionError.mode`. +- Call sites read `if mode is TransitionMode.BACKWARD:` instead of `if backward:`. + +Because the change is wire-invisible, there is **no** `backward=` alias and no +deprecation window. `mypy` drove completeness: every remaining `backward=` +keyword and `if backward` read was a type error until migrated. + +The close / reopen / release-revert behaviour and the `transition_forced` audit +event are **unchanged** — this is a rename of the selector, not a change to what +the selector selects (which remains the ADR-013 `reverse_transitions` lane). + +## Alternatives Considered + +### Alternative 1: Keep `backward: bool` + +**Pros**: zero churn; no migration for internal embedders. + +**Cons**: the opacity that motivated the change persists; the major-version +window — the one cheap moment to make a wire-invisible breaking rename — is +wasted. + +**Why rejected**: the cost is paid once at a major boundary; the readability gain +is permanent. + +### Alternative 2: A `str` literal / `StrEnum` (`mode="backward"`) + +**Pros**: marginally lighter; serialises trivially if it ever became wire-facing. + +**Cons**: a bare string is unvalidated at the call site — `mode="bacward"` type-checks +and fails at runtime. A plain `Enum` member is the strongest compile-time guard. + +**Why rejected**: the flag is internal, so the serialisation upside is moot, and +the validation downside is real. `Enum` over `StrEnum` because nothing needs the +value to *be* a string. + +### Alternative 3: Replace it *and* keep a `backward=` alias for one minor + +**Pros**: belt-and-braces for any out-of-tree embedder. + +**Cons**: an alias implies a wire/contract obligation that does not exist here; it +would carry dead translation code through 3.x for a parameter no published +surface exposes. + +**Why rejected**: there is no external consumer to protect — the call-site +enumeration proved it. An alias would be cargo-culted major-version discipline. + +## Consequences + +### Positive + +- Call sites are self-documenting: `mode=TransitionMode.BACKWARD` states intent. +- `mypy` mechanically guarantees no stray `backward` boolean survives. +- A future third direction (if ever needed) has a home. + +### Negative + +- A breaking change for any code that calls the internal `update_issue` / + `validate_transition` with `backward=...` or reads `InvalidTransitionError.backward` + (`backward=True` → `mode=TransitionMode.BACKWARD`; `.backward` → `.mode`). + +### Neutral + +- The wire surfaces (MCP, CLI, HTTP) are untouched — agents and federation + consumers see no difference. + +## Related Decisions + +- **Refines**: [ADR-013: Backward Edges in Workflow Templates](./ADR-013-backward-edges-in-workflow-templates.md) — the `backward=True` opt-in ADR-013 defined is now `mode=TransitionMode.BACKWARD`. The reverse-lane *semantics* are unchanged. +- **Refines**: [ADR-005: Workflow Enforcement and Explicit Cleanup Paths](./ADR-005-workflow-enforcement-and-cleanup-paths.md) +- **Related to**: [ADR-009: Response Shape Philosophy](./ADR-009-response-shape-philosophy.md) — `InvalidTransitionError` carries structured recovery data on the wire; this rename touches only the internal attribute, not that payload. + +## References + +- `docs/plans/2026-06-06-pr52-section4-3.0.0-items.md` §Task 3 +- `src/filigree/types/api.py` (`TransitionMode`, `InvalidTransitionError`) +- `src/filigree/templates.py` (`validate_transition`), `src/filigree/db_issues.py` (`update_issue`) +- CHANGELOG `[3.0.0]` — *Changed (BREAKING)*: "`TransitionMode` enum replaces the internal `backward: bool`" diff --git a/docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md b/docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md new file mode 100644 index 00000000..133201a4 --- /dev/null +++ b/docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md @@ -0,0 +1,126 @@ +# ADR-020: Transport-Bound Actor Identity — Deferred, Posture Made Honest + +**Status**: Proposed (Deferred to `filigree-81d3971467`) +**Date**: 2026-06-08 +**Deciders**: John (project lead) +**Context**: 3.0.0 release scoping. ADR-012 established that `actor` strings are unauthenticated claims; reviewers keep asking whether 3.0.0 *verifies* the caller. It does not — but it stops doing so *silently*. + +## Summary + +3.0.0 does **not** add transport-bound actor verification (binding an +authenticated transport to a proven `actor`/`author` identity). That work +remains deferred to `filigree-81d3971467`. What 3.0.0 *does* ship is honesty: +the previously-silent unverified posture is now **discoverable** on both wire +transports via an `actor_verification` object. This ADR records the +decision-to-defer and the posture surface, anchored on the ADR-012 threat model. +It is deliberately a short, complete ADR — the substantive threat analysis lives +in ADR-012; this one is the forward-decision marker the codebase points at. + +## Context + +[ADR-012](./ADR-012-actor-identity-threat-model.md) fixed the threat model: the +`actor` string is an *identifier*, not an *authentication credential*; the audit +trail records claims, not proofs; the trust boundary is the **transport**, not +the actor field. ADR-012 named transport-level identity verification a "2.3.0+ +work package," not a deliverable. + +Two facts forced a decision at the 3.0.0 boundary: + +1. **The unverified state was silent.** Over HTTP (and MCP-HTTP), the `actor` is + a self-asserted claim, and the `verified_actor` / `verified_author` columns + are correctly `NULL` — stamping the server's OS user would be a *false* + attestation. But callers had no signal that this was happening; a write + looked identical whether or not the identity behind it was vouched for. +2. **The verification substrate is not settled.** 3.0.0 is still landing the + federation token model (`WEFT_FEDERATION_TOKEN`, anchor auto-provisioning) and + the Loomweave/Weft rebrand. Binding a *verified identity* to a transport before + the token-identity story settles would build on shifting ground and risk a + second breaking change one minor later. + +## Decision + +We will **defer** transport-bound actor identity verification to +`filigree-81d3971467`, and in 3.0.0 ship only the *posture surface* that makes +the current (unverified-over-HTTP) state honest: + +- **MCP-stdio** stamps the OS identity → `actor_verification.verified = true`. +- **MCP-HTTP** cannot vouch for the caller → `verified = false`, `verified_actor` + is `NULL`, and the `actor` argument is recorded as a self-asserted claim. +- The posture is exposed as an `actor_verification` object + (`{verified, verified_actor, deferral, note}`) on the dashboard/Weft HTTP + surface via the `/api/health` `auth` scope, and on the MCP surface via + `mcp_status_get` (derived from live session state, so stdio reads `verified` + and HTTP reads `unverified`). + +Authentication itself — proving the caller is who the `actor` says — is **out of +scope for 3.0.0**. This ADR's status stays *Proposed (Deferred)* until +`filigree-81d3971467` lands the verification mechanism, at which point it is +revised to *Accepted* and records the chosen binding. + +## Alternatives Considered + +### Alternative 1: Implement transport-bound verification in 3.0.0 + +**Pros**: closes the gap reviewers point at; one fewer deferred item. + +**Cons**: depends on a federation-token identity binding that is itself still +moving in 3.0.0; risks a follow-on breaking change; expands the already-large +breaking bundle. + +**Why rejected**: building verification on an unsettled token model trades a known +deferral for an unknown rework. Defer until the substrate is stable. + +### Alternative 2: Leave the unverified state silent (ship nothing) + +**Pros**: zero work. + +**Cons**: violates the ADR-012 honesty principle — a caller cannot tell a vouched +write from a self-asserted one. The drop of `verified_*` was a silent +information loss. + +**Why rejected**: silence is the actual defect ADR-012 warns against; making the +posture discoverable is cheap and correct even while verification waits. + +### Alternative 3: Fold this into ADR-012 instead of a new ADR + +**Pros**: no new ADR number. + +**Cons**: ADR-012 is the *threat model* — a shipped, stable decision. The +code (CHANGELOG, `mcp_status_get`, `/api/health`) cites a forward +*decision-to-defer plus posture*; that is a distinct decision with its own +lifecycle (it flips to Accepted when `filigree-81d3971467` lands). Overloading +ADR-012 would blur a stable record with an in-flight one. + +**Why rejected**: the deferral is its own decision with its own status arc. + +## Consequences + +### Positive + +- The current state is **honest, not silent**: agents and operators can read the + `actor_verification` posture and know whether identity was vouched for. +- The deferral is recorded with its rationale, so the gap is tracked, not lost. + +### Negative + +- Over HTTP, the `actor` remains a self-asserted claim — downstream consumers + **must not** treat an HTTP-surface `actor` as authenticated. +- One more decision carries a *Proposed/Deferred* status until the follow-up lands. + +### Neutral + +- No wire-breaking change: `actor_verification` is additive on `/api/health` and + `mcp_status_get`. + +## Related Decisions + +- **Extends**: [ADR-012: Actor Identity Threat Model](./ADR-012-actor-identity-threat-model.md) — ADR-012 is the threat model; this ADR records the 3.0.0 decision to defer verification and surface the posture. +- **Related to**: [ADR-018: Loom Bearer-Token Auth](./ADR-018-loom-bearer-token-auth.md) — the federation token (`WEFT_FEDERATION_TOKEN`) authenticates the *transport*; transport-bound *actor* identity (this ADR) is the next layer up and depends on it settling. + +## References + +- Tracking issue: `filigree-81d3971467` — "Transport-bound actor identity verification" +- `docs/superpowers/specs/2026-06-05-transport-bound-actor-identity-design.md` +- `docs/superpowers/plans/2026-06-05-transport-bound-actor-identity.md` +- CHANGELOG `[3.0.0]` — *Fixed*: "HTTP / MCP-HTTP writes no longer silently drop `verified_author`/`verified_actor`" +- `mcp_status_get` / `GET /api/health` (`auth` scope) — the `actor_verification` posture object diff --git a/docs/architecture/decisions/ADR-029-entity-association-opacity.md b/docs/architecture/decisions/ADR-029-entity-association-opacity.md new file mode 100644 index 00000000..be31c549 --- /dev/null +++ b/docs/architecture/decisions/ADR-029-entity-association-opacity.md @@ -0,0 +1,210 @@ +# ADR-029: Entity-Association Opacity + +**Status**: Accepted +**Date**: 2026-05-16 (adopted Filigree-side, schema v15 / 2.1.0); rebrand pass 3.0.0 (schema v26) +**Deciders**: John (project lead) +**Context**: Filigree must let an issue reference a code entity (function, class, module, file) owned by a sibling product — Loomweave — without coupling the two products. This ADR was cited by the implementation before it was written down; this document backfills the decision the code already enforces. + +> **Numbering note.** Filigree's own ADR sequence otherwise runs 001–018. The +> number **029** is deliberate: it mirrors the suite-wide entity-association +> concept number (the peer Loomweave decision, originally *Clarion +> ADR-029-entity-associations-binding*), and it is the number cited throughout +> the Filigree code (`db_entity_associations.py`, `mcp_tools/entities.py`, +> `dashboard_routes/entities.py`, `migrations.py`, `db_schema.py`). The four +> numbered Decisions below are referenced from those modules — in particular +> **Decision 3** is cited verbatim as the contract for *who computes drift*. + +## Summary + +Filigree binds an issue to a sibling-product entity by storing an **opaque +external `entity_id` string** in an `entity_associations` table keyed by +`(issue_id, entity_id)`. Filigree never parses the ID grammar, never resolves it +against the sibling's runtime, and never computes drift — it stores the +caller-supplied `content_hash` verbatim at attach time and returns raw rows. +This keeps two products bound at the data layer with **zero coupling** to the +sibling's identity scheme, runtime, or release cadence. The cost is that +Filigree cannot, by itself, answer "is this binding stale?" — that is the +consumer's job (Decision 3). + +## Context + +Loomweave (the code-archaeology sibling) owns a queryable map of entities and +mints a stable identifier for each. An agent triaging an issue wants to bind it +to "the function this issue is about," and an agent reading code wants the +reverse: "what issues are about the entity I'm looking at?" + +The constraints: + +- **No runtime coupling.** Filigree must not embed a Loomweave client, call its + API on the write path, or fail a write because Loomweave is down or on a + different version. +- **No identity-grammar coupling.** The identifier scheme is Loomweave's to + evolve. It has already moved from a mutable **locator** + (`{plugin}:{kind}:{qualname}`) to a durable, opaque **SEI** + (`loomweave:eid:`, per the SEI authority decision; see ADR-017). Filigree + must survive that migration without a schema change to the *meaning* of the + column. +- **No schema coupling.** The binding must not be wedged into the existing + `file_associations` / `file_records` identity, which has six relational + consumers (see ADR-014). +- **Drift is real.** Code moves; a hash captured at attach time can diverge from + the entity's live hash. Someone must detect that — but Filigree cannot see the + live hash. + +## Decision + +We will store entity bindings as opaque rows and push every interpretation +concern across the product boundary. The contract is four numbered decisions. + +### Decision 1: The `entity_id` is opaque; Filigree never parses it + +`entity_id` is stored verbatim. It **may** be a `loomweave:eid:` SEI or a +legacy locator (`{plugin}:{kind}:{qualname}`) — Filigree treats both as opaque +strings and does not validate, canonicalise, or infer structure from them. The +only sanctioned inspection is a **prefix-level** check of the `loomweave:eid:` +marker, used solely by the one-time locator→SEI value migration +(`sei_backfill.py`); no read or write path depends on the ID's internal grammar. +Entity *kind* is never inferred from the ID — it is optional, caller-supplied +metadata (`entity_kind` / its synonym `external_entity_kind`). + +### Decision 2: No schema coupling — its own table, no discriminated union + +`entity_associations` is a standalone table. It is **not** merged with +`file_associations`, and `entity_id` is **not** routed through `file_records.id`. +Overloading the file-identity column with a discriminated union of "file id or +opaque entity id" would touch every `file_records.id` consumer for no benefit; +the binding earns its own table (this is the same overloading-avoidance reasoning +ADR-014 applies to the file-identity split). + +### Decision 3: Drift detection is the **consumer's** job + +Filigree stores the caller-supplied `content_hash` as `content_hash_at_attach` +**verbatim** at attach time, and the list endpoints return **raw rows**. +Filigree does **not** compute, cache, or surface a `drift_warning`. The consumer +— Loomweave's `issues_for` read path — compares the stored +`content_hash_at_attach` against the entity's **live** `entities.content_hash` at +query time and decides whether the binding is fresh or stale. + +This is deliberate and load-bearing: Filigree cannot see the live hash (that +would require the runtime coupling Decision 1 forbids), so it cannot be the +authority on freshness. It records what was true at attach time and hands the +comparison to the side that can see "now." Both `entity_association_list` and +`entity_association_list_by_entity` (and their HTTP equivalents) therefore return +rows without a drift verdict, by contract — a caller that wants freshness must do +the comparison, not expect Filigree to have done it. + +### Decision 4: Project isolation is by DB file + +The reverse lookup `entity_association_list_by_entity` returns every issue **in +this project's database** bound to a given `entity_id`. There is no tenant column +and no cross-project query: isolation between projects is the SQLite file +boundary itself. Every row the reverse index (`ix_entity_assoc_entity`) can reach +already belongs to the project hosting that database. + +## Surface + +Reachable over both transports (the `entity_id` is opaque on every one): + +- **MCP**: `entity_association_add`, `entity_association_remove`, + `entity_association_list`, `entity_association_list_by_entity`, + `finding_promote_and_attach_entity`. +- **HTTP**: `GET`/`POST /api/issue/{issue_id}/entity-associations`, + `DELETE …?entity_id=…`, and the reverse `GET /api/entity-associations?entity_id=…`. + +`add` is idempotent on the composite key `(issue_id, entity_id)`: re-attaching +refreshes `content_hash_at_attach` and `attached_at` while preserving the +original `attached_by` actor. + +## Rebrand note (3.0.0 / schema v26) + +The binding shipped (schema v15, 2.1.0) under the sibling's pre-rebrand name: the +column was `clarion_entity_id` and SEIs carried the `clarion:eid:` prefix. The +3.0.0 Loomweave/Weft rebrand renamed the column to `loomweave_entity_id` and the +v26 data migration rewrote every stored `clarion:eid:` prefix to `loomweave:eid:` +in place (across the association column, the `deleted_issues` tombstone +`entity_ids` array, and the association audit events). **The opacity contract +above is unchanged** — only the names moved. The HTTP/MCP request parameter is +and remains `entity_id` (opaque); the renamed identifier surfaces in the *row* +returned by the list endpoints as `loomweave_entity_id`. + +## Alternatives Considered + +### Alternative 1: Store a typed foreign key to a sibling entity table + +**Pros**: referential integrity; Filigree could validate the reference. + +**Cons**: couples Filigree's schema to Loomweave's; a Loomweave identity-scheme +change (locator→SEI) becomes a Filigree migration; Filigree cannot own a table it +does not write. + +**Why rejected**: the whole point is product decoupling — a typed FK is the +coupling we are avoiding. + +### Alternative 2: Resolve the entity against Loomweave's runtime on write + +**Pros**: Filigree could reject a bad `entity_id` and could compute drift itself. + +**Cons**: puts a cross-product network call on the write path; a write fails when +the sibling is down or version-skewed; binds release cadences together. + +**Why rejected**: violates the no-runtime-coupling constraint; trades a write +that always works for one that works only when two products agree. + +### Alternative 3: Compute drift inside Filigree + +**Pros**: a single `entity_association_list` call returns a freshness verdict. + +**Cons**: Filigree has no view of the entity's *live* hash — only the snapshot it +was handed at attach time. To compute drift it would have to call Loomweave +(Alternative 2) or cache live hashes (a second coupling). + +**Why rejected**: Filigree structurally cannot be the freshness authority; that +authority belongs to the side that can see "now" — hence Decision 3. + +### Alternative 4: Merge into `file_associations` / `file_records.id` + +**Pros**: one identity table. + +**Cons**: overloads a column with six relational consumers plus `scan_runs.file_ids` +JSON references; a discriminated-union FK touches more code than a new table. + +**Why rejected**: same overloading-avoidance as ADR-014 — the binding earns its +own table. + +## Consequences + +### Positive + +- **Zero coupling** to Loomweave's identity scheme, runtime, or release cadence; + the locator→SEI and Clarion→Loomweave migrations needed no change to the + binding's *meaning*. +- Writes never fail because of a sibling's availability or version. +- One drift vocabulary (`content_hash`) shared with the file-identity split + (ADR-014). + +### Negative + +- Filigree **cannot** answer "is this binding stale?" on its own — every + freshness consumer must run the Decision 3 comparison. +- Opaque IDs are **unvalidated**: a typo'd or malformed `entity_id` is stored as + faithfully as a correct one; Filigree will never flag it. +- The reverse lookup requires a dedicated index (`ix_entity_assoc_entity`). + +### Neutral + +- `entity_kind` is advisory metadata only; it never participates in + identity or drift. + +## Related Decisions + +- **Peer**: Loomweave's entity-associations decision (the cross-product concept this number mirrors). Filigree owns the *store* side; Loomweave owns the *consume/drift* side. +- **Related to**: [ADR-014: Registry Backend and File-Identity Displacement](./ADR-014-registry-backend-and-file-identity-displacement.md) — closes the *file*-side of the same Filigree↔sibling identity split, reusing this ADR's `content_hash` drift vocabulary. +- **Related to**: [ADR-017: SEI Conformance — Two-Axis Freshness and Backfill](./ADR-017-sei-conformance-two-axis-freshness-and-backfill.md) — the locator→SEI value migration that runs over the opaque `entity_id` stored here. + +## References + +- `src/filigree/db_entity_associations.py` — the CRUD + Decision 3 implementation +- `src/filigree/mcp_tools/entities.py`, `src/filigree/dashboard_routes/entities.py` — the wire surfaces +- `src/filigree/migrations.py` (`migrate_v25_to_v26`) — the rebrand data pass +- `src/filigree/sei_backfill.py` — the sanctioned `loomweave:eid:` prefix inspection +- CLAUDE.md — "Cross-product entity bindings (ADR-029)" diff --git a/docs/cli.md b/docs/cli.md index 709b1485..8aabf97b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI Reference -Most data commands support `--json` for machine-readable output (`--json` is supported by every issue/observation/file/finding/scanner/planning command but not by setup/diagnostic commands like `install`, `doctor`, and `session-context`, which produce human-only output). The `--actor` flag sets identity for the audit trail (default: `cli`) and works in either position — before the verb (`filigree --actor X update …`, group-level) or after it (`filigree update … --actor X`, per-verb). The post-verb value overrides the group-level one. +Most data commands support `--json` for machine-readable output (`--json` is supported by every issue/observation/file/finding/scanner/planning command and by `doctor`; setup commands like `install` and session bootstrap commands like `session-context` produce human-only output). The `--actor` flag sets identity for the audit trail (default: `cli`) and works in either position — before the verb (`filigree --actor X update …`, group-level) or after it (`filigree update … --actor X`, per-verb). The post-verb value overrides the group-level one. ## Contents @@ -85,7 +85,8 @@ filigree install --skills # Install Claude Code skills only filigree install --codex-skills # Install Codex skills only filigree install --mode=server # Switch MCP/hook configuration to server mode filigree doctor # Health check -filigree doctor --fix # Auto-fix what's possible +filigree doctor --fix # Repair local bindings and stale dashboard pointers +filigree doctor --fix --json # Machine-readable repair summary filigree doctor --verbose # Show all checks including passed ``` @@ -124,9 +125,35 @@ are reported with `filigree scanner enable --force`, and enabled bundled scanners whose runner command is missing point at `uv tool install --upgrade filigree`. +`doctor --fix` repairs local agent bindings and stale dashboard pointers only: +Claude/Codex MCP registration, Claude Code hooks, and stale ephemeral dashboard +PID/port files. It does not apply schema migrations, refresh generated context, +rewrite docs/instruction files, update `.gitignore`, change scanner +registrations, or mutate issues, observations, files, scan findings, scanner +results, or entity associations. + +In JSON mode, `doctor` emits the shared agent-readiness summary contract: + +```json +{ + "ok": true, + "checks": [ + {"id": "mcp.registration", "status": "ok", "fixed": false}, + {"id": "dashboard.port", "status": "fixed", "fixed": true} + ], + "next_actions": [] +} +``` + +The status vocabulary is `ok`, `failed`, and `fixed`. Stable check IDs include +`dashboard.port`, `mcp.registration`, `api.availability`, `auth.config`, +`scanner.results`, and `entity_associations.routes`; Filigree may include +additional product-specific IDs for local setup checks. + | Parameter | Type | Description | |-----------|------|-------------| -| `--fix` | flag | Auto-fix what's possible | +| `--fix` | flag | Repair local bindings and stale dashboard pointers | +| `--json` | flag | Emit the shared machine-readable doctor summary | | `--verbose` | flag | Show all checks including passed | ## Automation and Server @@ -743,11 +770,10 @@ filigree events # Event history for one issue ### `stats` Project statistics: counts by literal status name (`by_status`), template -status category (`by_category`), type, ready, and blocked. JSON also includes -the **deprecated** `status_name_counts` and `status_category_counts` maps — -exact duplicates of `by_status` and `by_category` (filigree-17694d2db8), kept as -compatibility aliases per ADR-009 §7 and scheduled for removal in the next -major. Read `by_status` / `by_category`. +status category (`by_category`), type, ready, and blocked. The deprecated +`status_name_counts` / `status_category_counts` JSON aliases (exact duplicates +of `by_status` / `by_category`) were **removed in 3.0.0** +(filigree-e4181ae767). Read `by_status` / `by_category`. ### `metrics` @@ -1021,9 +1047,17 @@ List code-health findings. Output: `ListResponse[T]` (`{items, has_more}`). | `--status` | string | Filter by status | | `--severity` | string | Filter by severity | | `--scan-source` | string | Filter by scanner | +| `--scan-run-id` | string | Filter by scan run | +| `--issue-id` | string | Filter by linked issue | +| `--rule-id` | string | Filter by rule/check ID (exact) | +| `--kind` | string | Filter by wardline finding kind (`defect`/`fact`/`classification`/`metric`/`suggestion`); `--kind defect` excludes engine telemetry | +| `--qualname` | string | Filter by wardline qualified name (exact) | +| `--suppression` | string | Filter by suppression. **Defaults to `active`** (un-suppressed/actionable) so accepted findings are hidden from the work view; pass `all` to include them, or `baselined`/`waived`/`judged` to select a specific accepted verdict | | `--limit` | integer | Max results | | `--offset` | integer | Skip first N results | +By default `list-findings` shows only **active** (un-suppressed) findings — a wardline-baselined/waived/judged finding is an already-accepted defect, not open work, so it is hidden unless you pass `--suppression all` (or a specific verdict). + ### `get-finding` Get a single finding by ID. @@ -1254,6 +1288,13 @@ changes, refresh managed project registrations with `filigree scanner enable --force`; `filigree doctor` reports bundled registrations that look stale. +Scanner POSTs that need `GET /api/scan-runs` history must send a globally +unique, non-empty `scan_run_id`. Empty run IDs are accepted for +fire-and-forget findings but are intentionally absent from scan-run history. +Filigree does not ingest raw SARIF at this endpoint; SARIF adapters must map +SARIF `partialFingerprints` or `fingerprints` into each posted +`finding.fingerprint` before sending scan-results. + ### `trigger-scan` Trigger a single-file scan. diff --git a/docs/concepts/architecture_scanner.md b/docs/concepts/architecture_scanner.md index 0f487165..eb0ff8fe 100644 --- a/docs/concepts/architecture_scanner.md +++ b/docs/concepts/architecture_scanner.md @@ -1,7 +1,7 @@ # Architecture Scanner: A Deterministic, Graph-Based Codebase Representation for AI Agents > **Status:** Concept — reviewed by 6-person design panel (2026-02-28). -> See [architecture_scanner_synthesis.md](https://github.com/tachyon-beep/filigree/blob/main/docs/concepts/architecture_scanner_synthesis.md) for the full debate record. +> See [architecture_scanner_synthesis.md](https://github.com/foundryside-dev/filigree/blob/main/docs/concepts/architecture_scanner_synthesis.md) for the full debate record. ## 1. System Objective diff --git a/docs/federation/contracts.md b/docs/federation/contracts.md index df4d33c8..07c31d77 100644 --- a/docs/federation/contracts.md +++ b/docs/federation/contracts.md @@ -1,13 +1,26 @@ # Filigree Federation Contracts -This directory documents filigree's published HTTP contracts for federation consumers — the stable, pinnable targets introduced by [ADR-002](https://github.com/tachyon-beep/filigree/blob/main/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md). +This directory documents filigree's published HTTP contracts for federation consumers — the stable, pinnable targets introduced by [ADR-002](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md). + +> **Authority note (added 2026-06-05).** The **route shapes, envelopes, generation +> definitions, and intake contracts in this document are Filigree-owned and +> authoritative** — including the entity-association routes at the classic +> `/api/issue/…` surface and `/api/entity-associations`. What is *not* owned here +> is the federation **roster and axiom**: those now live at `~/loom/doctrine.md` +> (5 realized members — Loomweave, Filigree, Wardline, Legis, Charter; Shuttle is a +> roadmap thought-bubble, not a member). The suite-level **cross-product contract +> index** — which lists every live cross-product binding and points back to each +> owning project's authoritative doc — lives at `~/loom/contracts-index.md`. Where +> this document names a peer set like "Loomweave + Wardline + Shuttle", read +> `~/loom/doctrine.md` for current membership; the endpoint specs themselves are +> unchanged. ## What a "contract" is here A **contract** is a named API generation at the HTTP surface. Filigree currently publishes two: - **`classic`** — the pre-federation `/api/*` surface as it existed through the 1.x series. Mostly un-prefixed (e.g. `/api/issue/{id}` singular, `/api/issues`, `/api/ready`), with one `/api/v1/` outlier, `POST /api/v1/scan-results`. (The per-endpoint table under "Living-surface alias decisions" below is the precise path reference.) Frozen: no new operations, no shape changes. Continues to be fully supported. Retirement requires a new ADR with 12 months of deprecation notice. -- **`loom`** — `/api/loom/*`. Introduced in 2.0. The federation-era generation, named for the Loom federation (Clarion + Wardline + Shuttle + filigree). Uses the unified `BatchResponse[T]` / `ListResponse[T]` envelopes, the closed `ErrorCode` enum, the `issue_id` vocabulary, and composed operations like `work_start`. +- **`loom`** — `/api/weft/*`. Introduced in 2.0. The federation-era generation, named for the [Weft federation](file:///home/john/loom/doctrine.md) (authoritative roster lives at `~/loom/doctrine.md`). Uses the unified `BatchResponse[T]` / `ListResponse[T]` envelopes, the closed `ErrorCode` enum, the `issue_id` vocabulary, and composed operations like `work_start`. The **living surface** at `/api/*` (no generation prefix) aliases the current recommended generation — as of 2026-04-24 that is `loom`. Living-surface endpoints are explicitly non-stability; production integrations across version boundaries must pin to a named generation. @@ -16,20 +29,22 @@ MCP and CLI reflect the living surface only. They evolve forward with each relea ## Authentication (opt-in, loom surface) — ADR-018 By default Filigree's HTTP API performs **no** inbound authentication: it is -loopback-only and the transport is the trust boundary ([ADR-012](https://github.com/tachyon-beep/filigree/blob/main/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md)). +loopback-only and the transport is the trust boundary ([ADR-012](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-012-actor-identity-threat-model.md)). -When an operator sets the **`FILIGREE_API_TOKEN`** environment variable on the -Filigree server, the **loom federation surface** is gated behind a bearer token -([ADR-018](https://github.com/tachyon-beep/filigree/blob/main/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md)): +When an operator sets the **`WEFT_FEDERATION_TOKEN`** environment variable on the +Filigree server (the deprecated `FILIGREE_FEDERATION_API_TOKEN` / `FILIGREE_API_TOKEN` +names are still read as a backward-compatible fallback, removal post-1.0), the +**loom federation surface** is gated behind a bearer token +([ADR-018](https://github.com/foundryside-dev/filigree/blob/main/docs/architecture/decisions/ADR-018-loom-bearer-token-auth.md)): -- **Enforced paths:** `/api/loom/*` and the living-surface federation aliases +- **Enforced paths:** `/api/weft/*` and the living-surface federation aliases that route to loom (today: `POST /api/scan-results`), including under the server-mode project mount (`/api/p/{key}/loom/…`). - **Not enforced:** the classic surface (`/api/issue/…`, `/api/issues`, `/api/v1/scan-results`), the local dashboard (`/`), and `/api/health`. The classic `/api/v1/scan-results` outlier is **not** gated — federation producers - should post to `/api/loom/scan-results` or `/api/scan-results`. -- **Request:** send `Authorization: Bearer `. The comparison + should post to `/api/weft/scan-results` or `/api/scan-results`. +- **Request:** send `Authorization: Bearer `. The comparison is constant-time. - **Rejection:** a missing/invalid token on an enforced path returns `401` with the standard envelope `{"error": "...", "code": "PERMISSION"}` and a @@ -98,29 +113,29 @@ def test_scan_results_success_shape(filigree_url): Living-surface aliases (`/api/` with no generation prefix) land per-endpoint as Phase C of the 2.0 federation work package mounts each loom endpoint. Each decision is recorded here so the precedent for "alias vs. classic-only" is auditable. -| Endpoint | Living-surface path | Loom path | Classic path | Status | Decision rationale | +| Endpoint | Living-surface path | Weft path | Classic path | Status | Decision rationale | | --- | --- | --- | --- | --- | --- | -| `POST` scan-results | `/api/scan-results` | `/api/loom/scan-results` | `/api/v1/scan-results` | aliased (2026-04-26, Phase C1) | Loom and classic publish at distinct paths (`/v1/` vs. `/loom/`), so the un-prefixed `/api/scan-results` does not collide with classic. Aliasing it to loom gives federation consumers (Clarion, Wardline, Shuttle) the recommended generation at the canonical path without hard-coding the `/loom/` prefix. The handler is wire-identical to `/api/loom/scan-results`; equivalence is pinned by `tests/util/test_generation_parity.py::TestLivingSurfaceEquivalenceScanResults`. | -| `POST` batch/update | n/a | `/api/loom/batch/update` | `/api/batch/update` | classic-and-loom only (2026-04-26, Phase C2) | Classic occupies `/api/batch/update` with `{updated, errors}`; loom uses `{succeeded, failed}`. An un-prefixed alias would collide with the existing classic handler. Federation consumers pin to `/api/loom/batch/update` until classic is retired. | -| `POST` batch/close | n/a | `/api/loom/batch/close` | `/api/batch/close` | classic-and-loom only (2026-04-26, Phase C2) | Same reasoning as batch/update — classic owns the un-prefixed path, loom-only alias deferred. | -| Single-issue CRUD (GET, POST, PATCH, /close, /reopen, /claim, /release, /comments, /dependencies, DELETE /dependencies/*) | n/a | `/api/loom/issues/{issue_id}/...` | `/api/issue/{id}/...` (singular) | classic-and-loom only (2026-04-26, Phase C3) | Classic uses `/api/issue/...` (singular); loom uses `/api/issues/...` (plural). Paths do not collide, so a living-surface alias at `/api/issues/{issue_id}/*` is technically possible. **Deliberately not added in C3** — the single-issue surface is the most-coupled federation entry point, and we want consumers to commit to a pinnable generation (`/api/loom/...`) until at least Phase D when the federation is operating in production. Reconsider when stability data warrants. | -| `POST` /claim-next | n/a | `/api/loom/claim-next` | `/api/claim-next` | classic-and-loom only (2026-04-26, Phase C3) | Classic owns the un-prefixed `/api/claim-next`; loom-only alias same reasoning as above. | -| `GET` /issues (list) | n/a | `/api/loom/issues` | `/api/issues` | classic-and-loom only (2026-04-26, Phase C4) | Classic owns the un-prefixed path with the stream-all behavior; loom adds real `?limit=&offset=` pagination wrapped in `ListResponse[IssueLoom]`. Alias would collide with classic's existing handler. | -| `GET` /ready | n/a | `/api/loom/ready` | `/api/ready` | classic-and-loom only (2026-04-26, Phase C4) | Same reasoning — classic occupies the un-prefixed path. | -| `GET` /search | n/a | `/api/loom/search` | `/api/search` | classic-and-loom only (2026-04-26, Phase C4) | Classic returns `{results, total}`; loom drops `total` per the strict `ListResponse[T]` envelope. Alias would collide. | -| `GET` /files (list) | n/a | `/api/loom/files` | `/api/files` | classic-and-loom only (2026-04-26, Phase C4) | Classic returns `PaginatedResult` (`{results, total, limit, offset, has_more}`); loom drops the `total/limit/offset` siblings per the unified envelope. Alias would collide. | -| `GET` /types | n/a | `/api/loom/types` | `/api/types` | classic-and-loom only (2026-04-26, Phase C4) | Classic owns the un-prefixed path with a bare list; loom wraps in `ListResponse[TypeSummaryLoom]`. Alias would collide. | -| `GET` /blocked, /findings, /observations, /scanners, /packs, /changes | deferred (alias-eligible) | `/api/loom/` | none | loom-only (2026-04-26, Phase C4) | No classic dashboard counterpart — these were MCP-only in the classic generation. **Living-surface aliases at `/api/` are eligible per the precedent rule but deferred to a later pass**, mirroring the C3 decision to defer single-issue surface aliases: federation consumers should commit to a pinnable generation (`/api/loom/...`) until at least Phase D when the federation is operating in production. Reconsider when stability data warrants. | -| `GET` /issues/{issue_id}/{comments,events,files} | n/a | `/api/loom/issues/{issue_id}/...` | none (classic uses singular `/issue/...`) | loom-only (2026-04-26, Phase C4) | Classic uses `/api/issue/{id}/files` (singular); loom uses plural symmetric with `/issues`. No collision but **deliberately not aliased** for the same reason as C3's single-issue surface — these are the most-coupled federation entry points; consumers commit to the loom generation. Loom adds GET counterparts for `/comments` and `/events` (classic exposed them only via MCP / POST). | -| `POST` findings/clean-stale | deferred (alias-eligible) | `/api/loom/findings/clean-stale` | none | loom-only (2026-05-30, ADR-015) | Findings retention surface — soft-archives stale `unseen_in_latest` findings to `fixed`, `scan_source`-scoped. No classic counterpart (retention was MCP/CLI-absent and CLI-only respectively). Living-surface alias deferred per the C4 precedent — federation consumers commit to `/api/loom/...`. | - -The pattern is illustrative for later C tasks: where a loom endpoint has no classic counterpart at the un-prefixed path, prefer aliasing **unless** the endpoint is on a coupled surface where pinning the generation matters more (single-issue surface in C3; per-issue list endpoints in C4); where classic and loom would collide, classic stays at `/api/` and loom is reachable only at `/api/loom/`. The decision for each endpoint lands in the commit that mounts the loom handler. +| `POST` scan-results | `/api/scan-results` | `/api/weft/scan-results` | `/api/v1/scan-results` | aliased (2026-04-26, Phase C1) | Weft and classic publish at distinct paths (`/v1/` vs. `/loom/`), so the un-prefixed `/api/scan-results` does not collide with classic. Aliasing it to loom gives federation consumers (Loomweave, Wardline, Shuttle) the recommended generation at the canonical path without hard-coding the `/loom/` prefix. The handler is wire-identical to `/api/weft/scan-results`; equivalence is pinned by `tests/util/test_generation_parity.py::TestLivingSurfaceEquivalenceScanResults`. | +| `POST` batch/update | n/a | `/api/weft/batch/update` | `/api/batch/update` | classic-and-loom only (2026-04-26, Phase C2) | Classic occupies `/api/batch/update` with `{updated, errors}`; loom uses `{succeeded, failed}`. An un-prefixed alias would collide with the existing classic handler. Federation consumers pin to `/api/weft/batch/update` until classic is retired. | +| `POST` batch/close | n/a | `/api/weft/batch/close` | `/api/batch/close` | classic-and-loom only (2026-04-26, Phase C2) | Same reasoning as batch/update — classic owns the un-prefixed path, loom-only alias deferred. | +| Single-issue CRUD (GET, POST, PATCH, /close, /reopen, /claim, /release, /comments, /dependencies, DELETE /dependencies/*) | n/a | `/api/weft/issues/{issue_id}/...` | `/api/issue/{id}/...` (singular) | classic-and-loom only (2026-04-26, Phase C3) | Classic uses `/api/issue/...` (singular); loom uses `/api/issues/...` (plural). Paths do not collide, so a living-surface alias at `/api/issues/{issue_id}/*` is technically possible. **Deliberately not added in C3** — the single-issue surface is the most-coupled federation entry point, and we want consumers to commit to a pinnable generation (`/api/weft/...`) until at least Phase D when the federation is operating in production. Reconsider when stability data warrants. | +| `POST` /claim-next | n/a | `/api/weft/claim-next` | `/api/claim-next` | classic-and-loom only (2026-04-26, Phase C3) | Classic owns the un-prefixed `/api/claim-next`; loom-only alias same reasoning as above. | +| `GET` /issues (list) | n/a | `/api/weft/issues` | `/api/issues` | classic-and-loom only (2026-04-26, Phase C4) | Classic owns the un-prefixed path with the stream-all behavior; loom adds real `?limit=&offset=` pagination wrapped in `ListResponse[IssueLoom]`. Alias would collide with classic's existing handler. | +| `GET` /ready | n/a | `/api/weft/ready` | `/api/ready` | classic-and-loom only (2026-04-26, Phase C4) | Same reasoning — classic occupies the un-prefixed path. | +| `GET` /search | n/a | `/api/weft/search` | `/api/search` | classic-and-loom only (2026-04-26, Phase C4) | Classic returns `{results, total}`; loom drops `total` per the strict `ListResponse[T]` envelope. Alias would collide. | +| `GET` /files (list) | n/a | `/api/weft/files` | `/api/files` | classic-and-loom only (2026-04-26, Phase C4) | Classic returns `PaginatedResult` (`{results, total, limit, offset, has_more}`); loom drops the `total/limit/offset` siblings per the unified envelope. Alias would collide. | +| `GET` /types | n/a | `/api/weft/types` | `/api/types` | classic-and-loom only (2026-04-26, Phase C4) | Classic owns the un-prefixed path with a bare list; loom wraps in `ListResponse[TypeSummaryLoom]`. Alias would collide. | +| `GET` /blocked, /findings, /observations, /scanners, /packs, /changes | deferred (alias-eligible) | `/api/weft/` | none | loom-only (2026-04-26, Phase C4) | No classic dashboard counterpart — these were MCP-only in the classic generation. **Living-surface aliases at `/api/` are eligible per the precedent rule but deferred to a later pass**, mirroring the C3 decision to defer single-issue surface aliases: federation consumers should commit to a pinnable generation (`/api/weft/...`) until at least Phase D when the federation is operating in production. Reconsider when stability data warrants. | +| `GET` /issues/{issue_id}/{comments,events,files} | n/a | `/api/weft/issues/{issue_id}/...` | none (classic uses singular `/issue/...`) | loom-only (2026-04-26, Phase C4) | Classic uses `/api/issue/{id}/files` (singular); loom uses plural symmetric with `/issues`. No collision but **deliberately not aliased** for the same reason as C3's single-issue surface — these are the most-coupled federation entry points; consumers commit to the loom generation. Weft adds GET counterparts for `/comments` and `/events` (classic exposed them only via MCP / POST). | +| `POST` findings/clean-stale | deferred (alias-eligible) | `/api/weft/findings/clean-stale` | none | loom-only (2026-05-30, ADR-015) | Findings retention surface — soft-archives stale `unseen_in_latest` findings to `fixed`, `scan_source`-scoped. No classic counterpart (retention was MCP/CLI-absent and CLI-only respectively). Living-surface alias deferred per the C4 precedent — federation consumers commit to `/api/weft/...`. | + +The pattern is illustrative for later C tasks: where a loom endpoint has no classic counterpart at the un-prefixed path, prefer aliasing **unless** the endpoint is on a coupled surface where pinning the generation matters more (single-issue surface in C3; per-issue list endpoints in C4); where classic and loom would collide, classic stays at `/api/` and loom is reachable only at `/api/weft/`. The decision for each endpoint lands in the commit that mounts the loom handler. ### C5 — `response_detail` query param on loom batch endpoints (2026-04-26) -Loom batch endpoints `POST /api/loom/batch/update` and `POST /api/loom/batch/close` accept a `response_detail=slim|full` query parameter. Default is `slim` — preserves the C2 wire shape (`SlimIssueLoom` items in `succeeded[]`). Federation consumers needing the full issue projection without a follow-up GET pass `response_detail=full` to receive `IssueLoom` items in `succeeded[]`. +Weft batch endpoints `POST /api/weft/batch/update` and `POST /api/weft/batch/close` accept a `response_detail=slim|full` query parameter. Default is `slim` — preserves the C2 wire shape (`SlimIssueLoom` items in `succeeded[]`). Federation consumers needing the full issue projection without a follow-up GET pass `response_detail=full` to receive `IssueLoom` items in `succeeded[]`. -**Locked rule: `newly_unblocked[]` stays `SlimIssueLoom` regardless of `response_detail`.** It represents *secondary* state — consumers branch on its presence to decide whether to refetch. Upgrading every entry to a full `IssueLoom` would inflate the response without buying federation consumers anything new. This rule applies to the loom batch endpoints (C2/C5) AND the loom single-issue close endpoint (`POST /api/loom/issues/{issue_id}/close`, C3) — both compute `newly_unblocked` and both keep it slim. +**Locked rule: `newly_unblocked[]` stays `SlimIssueLoom` regardless of `response_detail`.** It represents *secondary* state — consumers branch on its presence to decide whether to refetch. Upgrading every entry to a full `IssueLoom` would inflate the response without buying federation consumers anything new. This rule applies to the loom batch endpoints (C2/C5) AND the loom single-issue close endpoint (`POST /api/weft/issues/{issue_id}/close`, C3) — both compute `newly_unblocked` and both keep it slim. Classic batch endpoints do NOT accept `response_detail`. The parameter is a loom-only addition. @@ -146,7 +161,7 @@ vocabulary that landed in Phase C: - **Soft-transition warnings.** Single-issue mutation responses keep non-fatal workflow advisories in `data_warnings[]`. For - `issue_update` / `PATCH /api/loom/issues/{issue_id}`, soft enforcement + `issue_update` / `PATCH /api/weft/issues/{issue_id}`, soft enforcement warnings are returned in-band and recorded once as `transition_warning` events. @@ -179,7 +194,7 @@ vocabulary that landed in Phase C: surface. - **`issue_get.include_files` defaults to `False`.** Aligns MCP with - the loom HTTP `GET /api/loom/issues/{issue_id}` contract (defaulted + the loom HTTP `GET /api/weft/issues/{issue_id}` contract (defaulted to `False` since Phase C3). Federation consumers needing the file-association payload pass `include_files=true` explicitly. @@ -274,15 +289,15 @@ clients pinning to old `--json` output re-pin against the new schema. ADR-014 adds a project-scoped `registry_backend` flag for file identity. The default remains `local`: Filigree generates and owns `file_records.id` -as before. In `clarion` mode, auto-create file paths delegate identity -resolution to Clarion's read API: +as before. In `loomweave` mode, auto-create file paths delegate identity +resolution to Loomweave's read API: `GET /api/v1/files?path=&language=` -Clarion owns the response shape for that endpoint. Filigree expects +Loomweave owns the response shape for that endpoint. Filigree expects `{entity_id, content_hash, canonical_path, language}` and stores `entity_id` as `file_records.id`, `content_hash` as the file drift signal, -and `registry_backend = 'clarion'` on the row. The classic, loom, and +and `registry_backend = 'loomweave'` on the row. The classic, loom, and living scan-results response shapes are unchanged; only the file ID grammar changes under the opt-in backend. @@ -292,7 +307,7 @@ Capability probing is published on `GET /api/files/_schema`: { "config_flags": { "registry_backend": "local", - "registry_backend_features": ["local", "clarion"], + "registry_backend_features": ["local", "loomweave"], "allow_local_fallback": false } } @@ -300,13 +315,13 @@ Capability probing is published on `GET /api/files/_schema`: Federation consumers use this block to distinguish older Filigree builds from ADR-014-aware builds, and to detect whether the current project is -running in `local` or `clarion` mode. +running in `local` or `loomweave` mode. -Direct file registration is displaced in `clarion` mode. MCP `file_register` +Direct file registration is displaced in `loomweave` mode. MCP `file_register` and CLI `filigree register-file` return -`FILE_REGISTRY_DISPLACED` with the Clarion read URL to use instead. +`FILE_REGISTRY_DISPLACED` with the Loomweave read URL to use instead. Auto-create surfaces (`POST /api/v1/scan-results`, -`POST /api/loom/scan-results`, `POST /api/scan-results`, observations, and +`POST /api/weft/scan-results`, `POST /api/scan-results`, observations, and scanner helpers) route through the registry backend and should not emit that code unless a caller attempts direct local mutation. @@ -315,13 +330,13 @@ Operational launch and migration steps live in The Filigree-side contract is pinned by `tests/api/test_registry_backend_integration.py`, which runs loom scan ingest against both the default local backend and a live loopback implementation of -Clarion's read API. +Loomweave's read API. -## F5 — Deletion signal (`issue_deleted` on `/api/loom/changes`) (2026-05-30) +## F5 — Deletion signal (`issue_deleted` on `/api/weft/changes`) (2026-05-30) A hard delete (`issue_delete` / `filigree delete-issue`) removes the issue row and every dependent row in one transaction, so a deleted issue is otherwise -invisible to consumers reconciling off `GET /api/loom/changes` (the feed INNER +invisible to consumers reconciling off `GET /api/weft/changes` (the feed INNER JOINs `issues`). To close that, `issue_delete` writes a `deleted_issues` tombstone in the same transaction, and the changes feed surfaces it as a synthetic change record: @@ -353,11 +368,11 @@ labels) — reconcile deletions on an *unfiltered* feed. (`ON DELETE CASCADE`). Filigree's own reverse-lookup surfaces (`entity_association_list_by_entity`, `GET /api/entity-associations?entity_id=…`) read that table, so post-delete they correctly return nothing. **The hazard is -on the consumer side:** a consumer that mirrors those bindings (e.g. Clarion's +on the consumer side:** a consumer that mirrors those bindings (e.g. Loomweave's reverse lookup) and reconciles only the issue would keep the mirrored binding and surface a user-facing *phantom issue*. -`affected_entities` carries the sorted `clarion_entity_id`s the cascade removed, +`affected_entities` carries the sorted `loomweave_entity_id`s the cascade removed, captured before the cascade ran. It is **always present** on the changes feed — `[]` for live-issue change records, populated only on `issue_deleted`. @@ -365,7 +380,7 @@ captured before the cascade ran. It is **always present** on the changes feed *and* drop/tombstone every mirrored entity-association binding listed in `affected_entities` (or, equivalently, every binding your mirror keys to that `issue_id`). Do this on an unfiltered feed. The tracking issue for the -Clarion/Wardline consumer is `filigree-f3bf56554c`. +Loomweave/Wardline consumer is `filigree-f3bf56554c`. Pinned by `tests/api/test_loom_changes_deletion.py` (`TestDeletionCarriesAffectedEntities`) and the `deleted_issues` schema tests in @@ -374,12 +389,12 @@ Pinned by `tests/api/test_loom_changes_deletion.py` ## F6 — Scan-run identity & the tolerate-unknown intake contract (2026-05-31) **Decision: tolerate-unknown is permanent (option a).** A `POST` to -`/api/v1/scan-results`, `/api/loom/scan-results`, or `/api/scan-results` +`/api/v1/scan-results`, `/api/weft/scan-results`, or `/api/scan-results` carrying a `scan_run_id` Filigree has never seen is a **supported, stable contract** — not transitional leniency. Findings ingest normally; Filigree reconstructs the run in `GET /api/scan-runs` from `scan_findings.scan_run_id`. There is **no** Phase-0 "create the scan run first" handshake, and none is -planned. Federation producers (Clarion `clarion analyze`, Wardline, Shuttle) +planned. Federation producers (Loomweave `loomweave analyze`, Wardline, Shuttle) that mint their own run id and POST enrich-only may depend on this indefinitely. @@ -405,6 +420,10 @@ of the external producer contract. can never collide with Filigree's own `scan_trigger`-minted ids. - **Keep `scan_source` stable across a run.** History groups on `(scan_run_id, scan_source)`; a mid-run `scan_source` change splits the run. +- **Empty `scan_run_id` means fire-and-forget.** The field is optional and `""` + is accepted for legacy or fire-and-forget findings, but empty values are + intentionally excluded from `GET /api/scan-runs`. External producers that + want scan-run history MUST send a globally unique, non-empty id. - **No completion warning for an unknown run.** With `complete_scan_run=true` (the default), an unknown run has no `scan_runs` row to transition to `completed`, so Filigree **skips the completion attempt silently** — the @@ -424,9 +443,50 @@ and at the core-method level by `tests/core/test_files.py::TestScanRunId` (unknown-run/known-run/terminal-run warning distinction) and `tests/core/test_files.py::TestGetScanRunsCore`. -**Clarion may drop its "pending Filigree's confirmation" caveat** on +**Loomweave may drop its "pending Filigree's confirmation" caveat** on `docs/federation/contracts.md` (scan-results intake) and `REQ-FINDING-05`. +### SARIF-to-scan-results fingerprint adapter contract + +Filigree does not implement a SARIF parser on `/api/v1/scan-results`, +`/api/weft/scan-results`, or `/api/scan-results`. The native scan-results +contract accepts Filigree finding JSON. SARIF producers such as Wardline must +translate SARIF before POSTing, including mapping SARIF +`result.partialFingerprints` or `result.fingerprints` into each posted +finding's `fingerprint` field. + +Once supplied as `finding.fingerprint`, Filigree treats the fingerprint as the +finding's cross-run identity for that `scan_source`. It is preserved through +ingestion, `GET /api/weft/findings` readback, promote-by-fingerprint, dedup +across line movement, stale/fixed cleanup, and reopen-on-regress lifecycle +transitions. Fingerprint-less legacy findings remain supported and continue to +dedup by the file/source/rule/line heuristic. + +Pinned by +`tests/api/test_files_api.py::TestScanResultsFingerprintAPI::test_wardline_fingerprint_survives_native_ingest_readback_promote_dedup_and_lifecycle` +and by the `success_wardline_sarif_adapter_fingerprint` example in +`tests/fixtures/contracts/loom/scan-results.json`. + +## F7 — Finding suppression: surface-asymmetric defaults + first-class verdict (2026-06-10) + +Wardline stamps an accepted-defect verdict at `metadata.wardline.suppression_state ∈ {baselined, waived, judged}`; the key is **absent for an active finding** (absent ⇒ active). Filigree preserves it on ingest and refuses `finding_promote` on a suppressed finding without `force` (weft-171fc22a50). This section ratifies how the *read* surfaces treat that verdict — an asymmetry that is deliberate, and is a contract precisely because it is named here (filigree-2bdb878bd2). + +**1. The suppression default is surface-asymmetric — by design, not by accident.** + +| Surface | Default `suppression` | Rationale | +|---|---|---| +| **Agent** — MCP `finding_list`, CLI `list-findings` | `active` (suppressed rows hidden) | These are work-views an agent triages. An already-accepted defect is not fresh, open work; surfacing it manufactures false work (the promote-then-mint-a-P1 failure this ticket closed). Opt back in with `suppression=all` or a specific verdict. | +| **Machine / federation** — `GET /api/weft/findings` | `all` (every row returned) | A federation consumer reads the *complete* finding population and applies its own policy. Silently narrowing a machine-read contract is itself a seam-leak. Consumers pass an explicit `suppression=` filter (`active` / `baselined` / `waived` / `judged` / `all`). | +| **Human** — dashboard (per-file `/files/{id}/findings`) | inclusive (annotated, not hidden) | Findings render file-scoped with `suppression_state` shown; a human reviewing one file should see everything in context. | + +The default-hide lives **only at the agent surfaces**. The core `list_findings_global` primitive keeps an all-inclusive default (`suppression=None`), so internal callers are unaffected; `all` is an accepted no-op sentinel (identical to omitting the filter). + +**2. `suppression_state` is a first-class response field — never reach into `metadata.wardline`.** `GET /api/weft/findings` returns `suppression_state` (`str | None`) as a top-level field of each `ScanFindingWeft` item, lifted from the wardline metadata blob (mirroring the N6 `issue_status` lift). A consumer filters via the `?suppression=` query param or reads the top-level field; it must **not** parse the member-specific nested `metadata.wardline.suppression_state` to recover the verdict (that nested path is wardline's internal payload shape, not a cross-member contract). The advertised filter vocabulary is discoverable at `GET /api/files/_schema` → `valid_suppression_filters` (`{active, baselined, waived, judged, all}`). + +**3. Reversal trigger.** The machine surface stays inclusive-by-default **until a real federation consumer emerges that genuinely wants default-hide on `/api/weft/findings`.** If one does, revisit this decision (and align it with the agent-surface default) rather than letting consumers each reinvent the filter. Until then, inclusive holds. No consumer has requested it as of 2026-06-10. + +Pinned by `tests/api/test_files_api.py::TestWeftFindingsKindSuppressionFilters::test_default_stays_inclusive_unlike_agent_surfaces` (machine surface stays inclusive) and the agent-surface default-hide tests in `tests/mcp/test_finding_triage_tools.py` / `tests/cli/test_files_commands.py`. + ## When a contract evolves **Non-breaking additions** (new optional response fields, new optional request parameters with safe defaults) may land in-place without a new generation. Fixtures are updated to reflect the new shape; the `_meta.updated` field moves. @@ -435,9 +495,11 @@ and at the core-method level by `tests/core/test_files.py::TestScanRunId` ## Cross-references +- **Weft federation doctrine** (authoritative roster + axiom + composition law): `~/loom/doctrine.md`. +- **Weft cross-product contract index** (every live cross-product binding, pointing to each owning doc): `~/loom/contracts-index.md`. - **ADR-002** (the naming + lifecycle rules): `docs/architecture/decisions/ADR-002-api-generations-and-federation-posture.md`. - **2.0 work package** (the execution sequence): `docs/plans/2026-04-24-2.0-federation-work-package.md`. - **ADR-017 audit** (verifies classic-generation semantics are preserved on the 2.0 branch): `docs/plans/2026-04-24-adr017-audit.md`. - **SEI conformance position** (Filigree's obligations + emerging requirements for the SEI lock window): `docs/superpowers/specs/2026-06-01-filigree-roadmap-to-first-class.md` Appendix A. -- **Clarion ADR-004** (finding exchange format): `/home/john/clarion/docs/clarion/adr/ADR-004-finding-exchange-format.md`. -- **Clarion ADR-017** (severity + dedup): `/home/john/clarion/docs/clarion/adr/ADR-017-severity-and-dedup.md`. +- **Loomweave ADR-004** (finding exchange format): `/home/john/loomweave/docs/loomweave/adr/ADR-004-finding-exchange-format.md`. +- **Loomweave ADR-017** (severity + dedup): `/home/john/loomweave/docs/loomweave/adr/ADR-017-severity-and-dedup.md`. diff --git a/docs/federation/index.md b/docs/federation/index.md new file mode 100644 index 00000000..1cbb8019 --- /dev/null +++ b/docs/federation/index.md @@ -0,0 +1,180 @@ +--- +title: Filigree in the Weft Federation +description: How Filigree — the federation's work-state surface — engages each member of the Weft Federation, and the two structural facts (SEI and the weft generation) that hold the federation together. +--- + +# Filigree in the Weft Federation + +Filigree is the **work-state surface** of the [Weft Federation](https://github.com/foundryside-dev/weft) +— an agent-first family of small, local-first developer tools. Each member is +authoritative for exactly one domain, useful entirely on its own, and +**enrich-only — never load-bearing — when composed**: removing any one member +never breaks another member's core flow. Filigree's core flow (tracking work) +runs with no sibling present. + +This page explains what Filigree owns in the federation, the two structural +facts that let the members compose at all, and — member by member — how +Filigree engages each one, with each binding's caveat carried in full. + +!!! info "Authority" + This page is authoritative for **Filigree's own facts**. The way Filigree + *composes* with siblings is governed by the federation hub: the + [integration matrix](https://github.com/foundryside-dev/weft/blob/main/federation-map.md), + the [contracts index](https://github.com/foundryside-dev/weft/blob/main/contracts-index.md), + and the [asterisk register](https://github.com/foundryside-dev/weft/blob/main/asterisk-register.md). + Where a binding carries a caveat, it is reproduced here verbatim — dropping a + caveat overstates the binding's maturity. + +## What Filigree owns + +Filigree is the federation's system-of-record for **work state**. It owns: + +- **Issues** — the work items themselves, across **24 issue types** in **9 + workflow packs**, each with an enforced state machine. +- **Dependencies and the critical path** — a dependency graph with a ready-queue + and critical-path analysis. +- **Workflow state machines** — type-specific transitions; "ready" is not the + same as "startable", and the workflow refuses illegal transitions. +- **Observations** — a fire-and-forget scratchpad for incidental defects, with a + 14-day TTL unless promoted. +- **Files and scan-findings** — registered files and the findings attached to + them (the intake siblings post scan results into). +- **The `entity_associations` table** — bindings from a Filigree issue to an + **opaque external entity id**. Filigree stores the id verbatim and **never + parses it**; drift detection is the consumer's job (see + [SEI](#sei-the-connective-tissue) below). + +And — structurally — Filigree **hosts the named `weft` HTTP generation** that +several siblings use as their transport into the tracker. + +## The two structural facts + +Two facts hold the whole federation together. Everything in the per-member +matrix below rests on them. + +### SEI — the connective tissue + +Every cross-tool binding in the federation keys on the **Stable Entity Identity +(SEI)**, owned by **Loomweave** (the authority). SEI is a durable, opaque +identifier for a code entity that survives renames and moves — so a binding made +today still points at the right function after the code is refactored. + +- **SEI is LOCKED** (2026-06-05): the interface is frozen. Remaining member work + is conformance under the locked standard, not interface change. +- **Filigree stores SEI opaque and never parses it.** The `entity_associations` + table holds the id as a string; Filigree does not interpret its structure. +- **Drift detection is the consumer's job.** Filigree records a + `content_hash_at_attach` alongside the binding; the *consumer* (the sibling + tool reading the binding back) compares against that hash to detect that the + underlying entity has drifted. Filigree does not chase drift itself. + +A combination is only as strong as its weakest binding: a tool that keys on a +mutable locator instead of SEI silently orphans every combination it is in. + +See the [SEI standard](https://github.com/foundryside-dev/weft/blob/main/sei-standard.md). + +### The `weft` generation is the federation transport + +Filigree exposes its HTTP API in two **generations**: + +- **classic** — `/api/*` (and `/api/v1/*`), Filigree's own long-standing surface. +- **weft** — `/api/weft/*`, the **named federation transport**. + +Siblings pin to a *named, versioned generation* rather than to raw endpoints. +**Evolution is additive**: a breaking change ships as a *new* generation; an +existing generation is never mutated out from under the members pinned to it. + +Per Filigree **ADR-002 ("loose cooperation")**, every `weft` endpoint is +**functional with peers absent** — the transport is there for siblings to use, +but Filigree never depends on a sibling being present to serve it. + +## How Filigree engages each member + +Each cell below is a cross-tool binding from the federation +[integration matrix](https://github.com/foundryside-dev/weft/blob/main/federation-map.md). +Section numbers (§) reference the +[contracts index](https://github.com/foundryside-dev/weft/blob/main/contracts-index.md). + +### Loomweave ↔ Filigree — entity ⇄ issue + +**Binding (contracts §1).** Loomweave entities and Filigree issues are bound +through the `entity_associations` table, which lives **on Filigree's side**. +Loomweave's reverse lookup (`issues_for`) makes the binding **drift-aware**: +given an entity, it finds every issue bound to it and can tell when the entity +has changed since attach. + +Filigree stores the entity id (an SEI going forward) **opaque** and never parses +it; drift is detected by the consumer against `content_hash_at_attach`. This is +the cleanest, most mature binding Filigree has — it is also the surface Legis +reuses for sign-offs (§7, below). + +The surface is reachable over Filigree's **classic** generation +(`GET|POST /api/issue/{issue_id}/entity-associations`, +`DELETE …?entity_id=…`, and `GET /api/entity-associations?entity_id=…` for the +reverse lookup) and over MCP (`entity_association_add`, +`entity_association_remove`, `entity_association_list`, +`entity_association_list_by_entity`). + +### Wardline → Filigree — findings become tracked work + +**Binding (contracts §4).** Wardline trust-boundary findings reach Filigree's +scan-results intake, where they become tracked work — and the emit **pins each +finding's suppression provenance** so a baselined/waived/judged finding is not +re-minted as a fresh issue on ingest. + +!!! warning "Caveat — asterisk A-1 (LIVE)" + Today the (Wardline, Filigree) finding flow is **pipeline-coupled through + Loomweave's SARIF translator**: the legacy path routes Wardline's SARIF + through Loomweave's `loomweave sarif import` into the classic + `POST /api/v1/scan-results`. + + Wardline's **native Filigree emitter has shipped** (posting directly to the + federation generation, `POST /api/weft/scan-results`), **and** Filigree's + receiving route is **shipped on `release/3.0.0`**. But asterisk **A-1 stays + LIVE** until (Wardline, Filigree) composition **with Loomweave absent** is + demonstrated **end-to-end** — and that live e2e is currently **skipped**. + Agreement to the direction is not retirement; the asterisk retires only when + the Loomweave-absent composition is *demonstrated*, not merely agreed. + + So: this binding is **shipping, asterisk live** — not yet fully direct/done. + See [asterisk-register.md A-1](https://github.com/foundryside-dev/weft/blob/main/asterisk-register.md). + +### Legis → Filigree — governed sign-offs on issues + +**Binding (contracts §7).** Legis binds **SEI-keyed governed sign-offs** to +Filigree issues, reusing the entity-association surface (§1) plus its own +sign-off endpoints. **Filigree retains issue-lifecycle authority**; Legis adds +governance on top — it does not take ownership of the issue's state machine. + +### Charter → Filigree — requirement ↔ work (planned) + +**Binding — PLANNED only.** Charter is designed to link requirements to Filigree +work items (requirement ↔ work), but **Charter is scaffold-state**: the +federation adapter is designed in ADRs and **not yet built**. Treat this binding +as a future, not a current capability. + +## What is *not* in the federation + +To avoid overstating the architecture, three explicit anti-claims: + +- **There is no `weft://` URI scheme.** That design space is **closed by SEI** — + do not imply such a scheme exists. +- **There is no federation registry or broker.** Members compose pairwise on + SEI and the `weft` transport; there is no central runtime, registry, or broker + process. (The federation runs **zero** brokers by design.) +- **Lacuna is not a member.** Lacuna is the deliberately-flawed demonstration + specimen the whole federation is *run against* — point the Weft tools at it and + they pick up its seeded bugs. It is adjacent to the federation, not part of it. + +## Further reading + +- [Federation hub (`weft`)](https://github.com/foundryside-dev/weft) — the + authoritative integration matrix, contracts, and asterisk register. +- [Doctrine](https://github.com/foundryside-dev/weft/blob/main/doctrine.md) — the + federation axiom and the failure test every binding must pass. +- [SEI standard](https://github.com/foundryside-dev/weft/blob/main/sei-standard.md) + — the connective tissue every binding keys on. +- [Federation map](https://github.com/foundryside-dev/weft/blob/main/federation-map.md) + — the at-a-glance integration matrix this page draws from. +- [Federation contracts](contracts.md) — Filigree's own contract detail for the + bindings above. diff --git a/docs/federation/registry-backend-launch-runbook.md b/docs/federation/registry-backend-launch-runbook.md index 268236c1..f1e12fc7 100644 --- a/docs/federation/registry-backend-launch-runbook.md +++ b/docs/federation/registry-backend-launch-runbook.md @@ -1,24 +1,24 @@ # Registry-Backend Launch Runbook This runbook covers Filigree ADR-014 rollout when a project opts into -Clarion-owned file identity. Filigree-only projects do not need this runbook: +Loomweave-owned file identity. Filigree-only projects do not need this runbook: `registry_backend: local` is still the default and keeps existing behavior. ## Preconditions - Filigree is built with ADR-014 support. Verify `GET /api/files/_schema` includes `config_flags.registry_backend_features` - with both `local` and `clarion`. -- Clarion Sprint 3 C-WP10.1 through C-WP10.4 are deployed for the sibling - project. At minimum, `clarion serve` must expose + with both `local` and `loomweave`. +- Loomweave Sprint 3 C-WP10.1 through C-WP10.4 are deployed for the sibling + project. At minimum, `loomweave serve` must expose `GET /api/v1/files?path=&language=` and return `{entity_id, content_hash, canonical_path, language}`. - The operator has a restorable backup of `.filigree/filigree.db`. -- The Clarion base URL is stable from the Filigree process. +- The Loomweave base URL is stable from the Filigree process. ## Fresh Project Setup -1. Start Clarion's read API for the same project/worktree. +1. Start Loomweave's read API for the same project/worktree. 2. Probe a known file: ```bash @@ -28,8 +28,8 @@ Clarion-owned file identity. Filigree-only projects do not need this runbook: 3. Configure `.filigree.conf`: ```yaml - registry_backend: clarion - clarion: + registry_backend: loomweave + loomweave: base_url: http://127.0.0.1:9111 timeout_seconds: 5 allow_local_fallback: false @@ -41,30 +41,30 @@ Clarion-owned file identity. Filigree-only projects do not need this runbook: curl http://127.0.0.1:8377/api/files/_schema ``` - The response must show `registry_backend: clarion`. + The response must show `registry_backend: loomweave`. 5. Submit a small scan-result payload and verify the stored file ID is a - Clarion entity ID rather than a Filigree-native `*-f-*` ID. + Loomweave entity ID rather than a Filigree-native `*-f-*` ID. ## Existing Project Migration 1. Stop writers that can create file records. 2. Back up `.filigree/filigree.db` and keep the backup outside the project database directory. -3. Configure `.filigree.conf` for `registry_backend: clarion` and the Clarion +3. Configure `.filigree.conf` for `registry_backend: loomweave` and the Loomweave base URL. 4. Run the dry run: ```bash - uv run filigree migrate-registry --to clarion --dry-run --json + uv run filigree migrate-registry --to loomweave --dry-run --json ``` -5. Inspect every `unresolved` row. Delete stale file rows or repair Clarion +5. Inspect every `unresolved` row. Delete stale file rows or repair Loomweave indexing before executing. Do not execute with unresolved rows. 6. Execute with a manifest: ```bash - uv run filigree migrate-registry --to clarion --execute --manifest registry-migration.json --json + uv run filigree migrate-registry --to loomweave --execute --manifest registry-migration.json --json ``` 7. Start Filigree and check: @@ -80,13 +80,13 @@ Clarion-owned file identity. Filigree-only projects do not need this runbook: ## Rollback Rollback is manifest-based and intended for immediate recovery before new -Clarion-mode writes accumulate: +Loomweave-mode writes accumulate: ```bash uv run filigree migrate-registry --rollback registry-migration.json --json ``` -After rollback, set `registry_backend: local` or stop Filigree until Clarion is +After rollback, set `registry_backend: local` or stop Filigree until Loomweave is healthy. Re-run `GET /api/files/_schema` and a small scan ingest before returning writers to service. @@ -96,58 +96,58 @@ There is no supported `migrate-registry --to local` reconstruction path after the rollback manifest is lost. The manifest is the only artifact that records the old Filigree-local file IDs and every rewritten reference. If it is missing, restore the pre-migration database backup from step 2, or keep the project in -`clarion` mode and repair Clarion availability/indexing. Do not attempt a +`loomweave` mode and repair Loomweave availability/indexing. Do not attempt a hand-written local rollback against a live database. ## Failure Modes -- If Clarion is unreachable in `clarion` mode, auto-create write paths return +- If Loomweave is unreachable in `loomweave` mode, auto-create write paths return `503 Service Unavailable` with an IO error. - `--allow-local-fallback` is for single-operator recovery. It routes auto-creates through `LocalRegistry` while the project remains configured for - `clarion`; do not leave it enabled after the incident. + `loomweave`; do not leave it enabled after the incident. - Direct local file registration returns - `FILE_REGISTRY_DISPLACED`. Use Clarion's read API instead. + `FILE_REGISTRY_DISPLACED`. Use Loomweave's read API instead. - `entity_associations` is a peer primitive and is not migrated by `migrate-registry`; file identity displacement is additive over it. - **Briefing-blocked files surface as `RegistryFileNotFoundError` (HTTP 404 - from Clarion).** A scan-results POST that targets a file whose Clarion entity + from Loomweave).** A scan-results POST that targets a file whose Loomweave entity is `briefing_blocked` will fail rather than mint a shadow row. To diagnose: - 1. Query Clarion directly: `curl 'http://127.0.0.1:9111/api/v1/files?path=&language='`. + 1. Query Loomweave directly: `curl 'http://127.0.0.1:9111/api/v1/files?path=&language='`. A 404 with the file otherwise present in the project is the briefing-block signature. - 2. Inspect the entity properties in Clarion to confirm `briefing_blocked` is - set, then lift the block in Clarion (or accept that findings for the + 2. Inspect the entity properties in Loomweave to confirm `briefing_blocked` is + set, then lift the block in Loomweave (or accept that findings for the blocked file will not be ingested while the block is in place). 3. Re-run the failed scan-results ingest once the block is lifted. This behaviour is intentional under ADR-014 §"Briefing-block masking". -## Validating Against a Live Clarion Build +## Validating Against a Live Loomweave Build The Filigree test suite ships a Phase D end-to-end test that spawns -`clarion serve` against a tempdir project and asserts that a Filigree -scan-results ingest threads Clarion's entity ID into stored file records. +`loomweave serve` against a tempdir project and asserts that a Filigree +scan-results ingest threads Loomweave's entity ID into stored file records. The test is opt-in by tool availability: ```bash # Prerequisite: both binaries built and on PATH. -which clarion filigree +which loomweave filigree -# Run only the e2e test (skips automatically when clarion is absent): -uv run pytest tests/integration/test_clarion_phase_d_e2e.py -m integration -v +# Run only the e2e test (skips automatically when loomweave is absent): +uv run pytest tests/integration/test_loomweave_phase_d_e2e.py -m integration -v # Or filter to the integration marker across the suite: uv run pytest -m integration ``` -The test creates its own tempdir project (calls `clarion install`, -writes `clarion.yaml` with an HTTP bind on a free loopback port, spawns -`clarion serve`) so no project layout is required on disk. CI lanes that -also build Clarion can opt in by including the integration marker in +The test creates its own tempdir project (calls `loomweave install`, +writes `loomweave.yaml` with an HTTP bind on a free loopback port, spawns +`loomweave serve`) so no project layout is required on disk. CI lanes that +also build Loomweave can opt in by including the integration marker in their pytest invocation; lanes that do not will silently skip. ## Ownership Boundary Filigree issues for ADR-014 track Filigree code, schema, tests, and docs. -Clarion Sprint 3 work for C-WP10 is tracked in `/home/john/clarion/.filigree/` +Loomweave Sprint 3 work for C-WP10 is tracked in `/home/john/loomweave/.filigree/` and should not be filed or closed from the Filigree tracker. diff --git a/docs/federation/sibling-adoption-prompt.md b/docs/federation/sibling-adoption-prompt.md new file mode 100644 index 00000000..9d33bcff --- /dev/null +++ b/docs/federation/sibling-adoption-prompt.md @@ -0,0 +1,105 @@ +# Drop-in for wardline / loomweave / legis — filigree did the weft.toml config cutover + +> Paste this into the other member repos. It's a heads-up + an adoption contract, +> not a fire drill. Most of it is "here's what converged on the filigree side; pick +> it up on your own cadence." One item is a handoff the operator blessed +> (loomweave — that's the shared-schema merge). Precise and pre-verified. + +## What filigree just did (so you can reason about us) + +1. **Migrated our own identity off `.filigree.conf` into `.weft/filigree/config.json`** + — the subtree we're the sole writer of. `filigree init` imports a legacy conf + into config.json (conf-wins on the fields that were authoritative) and **retires + it** (`.filigree.conf` → `.filigree.conf.imported`, an audit breadcrumb), + crash-convergently. Fresh installs are **born confless**. Our project anchor is + now the **presence of `.weft/filigree/`** — not the conf, not `weft.toml`. +2. **We NEVER write `weft.toml`.** Not init, not install, not doctor. It is + operator-authored, read-only for us (C-9b / the C-4 multi-writer truncation + lesson). We read only `[filigree].store_dir` today, and `[].url` later. +3. **We boot with no `weft.toml`** (the C-9c deletion test) and treat a malformed + `weft.toml` as **absent** on every discovery/runtime path. The one strict spot + is the mutating `init` path, which refuses a present-but-unreadable file rather + than auto-migrating over a config that may pin an operator store_dir. + +Delete our `.weft/filigree/` and you break nothing of yours; we treat yours the same. + +## The reader contract to share (you mostly already ship it) + +Language-agnostic, byte-compatible with what legis (`config.py`) and loomweave +(`store.rs`) already ship. Any new `weft.toml` read should mirror these 7 steps: + +1. Resolve `weft.toml` at `project_root / "weft.toml"` — **no independent walk-up**. +2. **Absent** → keys unset, defaults. Never an error. +3. **Malformed** (syntax / non-UTF-8 / OS read error) → **treat as ABSENT** (C-9c). + Warn at most; **never hard-fail** (that would split the federation on the file). +4. Missing `[]` table / key → unset → next rung / default. +5. Wrong-type / empty value → ignore (warn), fall through. +6. Unknown keys/tables → silently ignored (forward-compat). +7. **Strictly read-only** — never write/create/rewrite `weft.toml`. + +One member-local exception: a *mutating* install/init/migrate path MAY use a strict +variant (absent → proceed; malformed → refuse), because silently treating +broken-as-absent there can relocate an operator-pinned store. Keep strictness on +write paths only. + +## Three disjoint precedence ladders — don't collapse them + +- **Identity** (name/prefix/mode/registry/…): your own member config wins; + `weft.toml` does **not** override identity. No env tier for store/identity. +- **Store location**: `weft.toml [].store_dir` (project-relative, under-root) + > your default. The only thing `weft.toml` overrides for the store. +- **Shared sibling keys** (`[].url`, reserved `enabled`): the 5-rung ladder + loomweave ships — `flag › env WEFT__URL › weft.toml [X].url › .weft//ephemeral.port › default`. + +## Shared-key schema — loomweave, here's the reassignment (operator-blessed) + +**loomweave:** the operator reassigned the lead of the shared `weft.toml` schema +proposal **`weft-a2f4cf95c7`** from you to filigree. This is **not** a land-grab and +**not** filigree shipping a federation contract solo: + +- Your draft **`docs/loomweave/proposals/C-9-shared-weft-toml-schema.md` ("009") is + real and is the basis** — filigree wrote its **half** + (`filigree/docs/federation/weft-toml-schema.md`), which **agrees with 009** and + closes its four §5 open questions. The operator will **merge 009 ⊕ filigree's half** + at the weft hub into one blessed schema. Your shipped reader code + (`store.rs::sibling_url`, `filigree_url.rs`) is the reference; we **ratify** your + rung-order and `WEFT__URL` spelling, we don't override them. +- If the reassignment isn't what you expected, **say so** — that's a coordination + signal, not a steamroll. + +The agreed shape (one fact, one home — each member's own top-level table; no nested +`[a.b]`, no `[federation]` mega-table): + +```toml +[filigree] +store_dir = ".weft/filigree" +url = "http://127.0.0.1:8377" # SHARED: read by siblings +# enabled = true # RESERVED — tolerate, don't act on yet + +[loomweave] +url = "http://127.0.0.1:9000" +[legis] +store_dir = ".weft/legis" +url = "http://127.0.0.1:9100" +[wardline] +url = "http://127.0.0.1:9200" +``` + +**Not yet shipped:** filigree does **not** bake a sibling-`url` reader this pass — +we reserve the surface and publish our own endpoint at `.weft/filigree/ephemeral.port` +(so you can already discover us). Nobody bakes the cross-read until the **hub pins** +the rung order + env spelling (C-9d). + +## Who this asks what + +- **wardline:** you're the real multi-sibling consumer (you already retired + `[wardline.*].url` — thank you). When the merged schema is hub-pinned, your + re-integration against `[].url` is a live cross-member constraint we'll + align on *before* you wire it — ping us. +- **loomweave:** the `weft-a2f4cf95c7` reassignment above. Your 009 + code are the + reference; filigree drives the doc + hub pin. +- **legis:** you're the member-private-form reference (`[legis].store_dir`) and we + copied your fail-soft semantics. Nothing required beyond knowing your shared home + is `[legis].url` (operator-written) if/when you expose an endpoint. + +That's it — the filigree half is a no-op for your stores. Questions → the filigree side. diff --git a/docs/federation/weft-toml-schema.md b/docs/federation/weft-toml-schema.md new file mode 100644 index 00000000..7329530c --- /dev/null +++ b/docs/federation/weft-toml-schema.md @@ -0,0 +1,131 @@ +# Proposal (filigree half): shared `weft.toml` key layout + +**Status**: DRAFT — filigree authors; **to be merged at the weft hub with +loomweave's `C-9-shared-weft-toml-schema.md` ("009")** into one hub-blessed +schema. (weft-a2f4cf95c7, reassigned loomweave → filigree by the operator, +2026-06-10.) +**Author**: filigree +**Merges with**: `loomweave/docs/loomweave/proposals/C-9-shared-weft-toml-schema.md` +**Tracks**: weft `conventions.md` C-9(d), conflict-register §A-14, glossary §8. +**Reference readers (shipped)**: legis `src/legis/config.py` (`[legis].store_dir`); +loomweave `crates/loomweave-core/src/store.rs` (`sibling_url`, `[].url`); +filigree `src/filigree/core.py` (`resolve_store_dir`, `[filigree].store_dir`). + +--- + +## 0. Relationship to loomweave-009 + +This is **not a competing design.** loomweave-009 already pins the shared layer +well, and filigree **agrees with it** on the load-bearing decisions. This document +exists to (a) close 009's four open questions with filigree's vote — filigree is +the most-read endpoint (`[filigree].url` is consumed by wardline, loomweave *and* +legis), so it has the strongest stake — and (b) record the one thing 009 cannot +see from loomweave's seat: how filigree's **own config** relates to its +`[filigree]` table after the config-anchor cutover (filigree-4bf16e64b6). + +**Provenance note (correcting a draft error):** loomweave-009 is a real, shipped +draft (`C-9-shared-weft-toml-schema.md`) *and* loomweave has shipped the reference +reader code (`store.rs::sibling_url`, `filigree_url.rs::resolve_filigree_url`). +The merge is **doc-009 ⊕ this doc**, ratified against that shipped code — not a +greenfield contract. + +## 1. Agreements with 009 (carried verbatim, not re-opened) + +- **§2.1 home** — a shared fact about member *X* lives **once** at the top-level + `[X]` table, read by any member; cross-read allowlist is `url` (+ reserved + `enabled`); no `[wardline.filigree]`-style duplication (§8 clash rule). +- **§2.2 precedence** — `flag › env WEFT__URL › weft.toml [X].url › + .weft//ephemeral.port › default`, with `weft.toml` (rung 3) **above** on-disk + discovery (rung 4) for the operator-declared remote-host case. +- **§2.3 invariants** — malformed = absent (NORMATIVE, never hard-fail); + operator is sole writer; one fact one home; forward-compatible parse (unknown + tables/keys ignored). + +## 2. filigree's answers to 009 §5 (the four open questions) + +| # | 009's question | filigree's vote | +|---|---|---| +| 1 | Member-table home (§2.1) vs a `[federation]` table (§4-B)? | **§2.1 member-table.** It keeps "everything about X" in one place and generalises to `enabled` without a parallel table. (Same as 009's recommendation.) | +| 2 | Confirm `weft.toml [X].url` (rung 3) **above** on-disk discovery (rung 4)? | **Confirm 3 > 4.** loomweave already **shipped** this (`filigree_url.rs`, in-code "Outranks on-disk discovery by design"); the hub is ratifying shipped behaviour, not choosing fresh. Local-only federations declare no `url` and are unaffected. | +| 3 | Pin `enabled` in v1, or `url`-only? | **`url`-only is live; `enabled` is RESERVED** (readers MUST tolerate its presence/absence but bake **no** semantics until the hub pins "absent = enabled?" and "advisory vs gating"). | +| 4 | Standardize `WEFT__URL` env spelling? | **Yes — `WEFT__URL`** (e.g. `WEFT_FILIGREE_URL`), matching loomweave's shipped `SOURCE_ENV`. Baking a *different* spelling would be the real compat break. Distinct from the inbound `WEFT_FEDERATION_TOKEN` and the registry `WEFT_TOKEN` (same `WEFT_*` family, different nouns — no collision). | + +## 3. The filigree-specific config split (what 009 can't see) + +filigree is the only member whose **identity** (project name, prefix, mode, db +location, registry backend, loomweave block) is non-trivial. After the +config-anchor cutover (filigree-4bf16e64b6): + +- **Identity lives in `.weft/filigree/config.json`** — filigree's sole-writer + subtree. It is NOT in `weft.toml` (the C-9c deletion test forbids putting + identity where `rm weft.toml` would brick the member). The project **anchor** + is the *presence of `.weft/filigree/`*, never `weft.toml`. +- **`weft.toml [filigree]` carries only operator overlays filigree READS:** + `store_dir` (member-private relocation) and the shared `url` (everyone reads). + +```toml +# weft.toml — operator-authored, project root, NEVER written by filigree +[filigree] +store_dir = ".weft/filigree" # member-PRIVATE: read only by filigree (relocation) +url = "http://127.0.0.1:8377" # SHARED: filigree's endpoint, read by siblings +# enabled = true # SHARED (RESERVED — tolerate, don't act on) + +[loomweave] +url = "http://127.0.0.1:9000" +[legis] +store_dir = ".weft/legis" +url = "http://127.0.0.1:9100" +[wardline] +url = "http://127.0.0.1:9200" +``` + +**Three disjoint precedence ladders — do NOT collapse** (filigree's reader proves +they never overlap): + +1. **Identity** (name/prefix/mode/db/registry/loomweave): `config.json` > + built-in defaults. `weft.toml` does **not** override identity. No env tier. +2. **Store location**: `weft.toml [filigree].store_dir` (project-relative, + under-root only) > `.weft/filigree/` > legacy `.filigree/` > default. The + ONLY thing `weft.toml` overrides for filigree — and it is *relocation*, not + identity. +3. **Shared sibling keys** (`[].url`, reserved `enabled`): the 009 5-rung + ladder above. + +## 4. filigree's scope this pass (honest about what is and isn't shipped) + +- **filigree READS** `[filigree].store_dir` today (`resolve_store_dir`). +- **filigree PUBLISHES** its own endpoint at `.weft/filigree/ephemeral.port`, so + siblings can already discover it (C-9e). +- **filigree does NOT yet ship a sibling-`url` cross-reader.** The schema + *reserves* that surface; filigree implements the cross-read only **after the + hub pins** the rung order + env spelling (C-9d: "no member bakes until pinned"). + This proposal does the pinning *proposal*; it is not itself the pin. + +## 5. Reader contract (language-agnostic; byte-compatible with legis + loomweave) + +1. Resolve `weft.toml` at `project_root / "weft.toml"` — **no independent + walk-up**; binary open; standards-compliant TOML parser. +2. Absent → all keys unset, defaults. Never an error. +3. Malformed (syntax / non-UTF-8 / OS read error) → **treat as ABSENT** (C-9c); + warn at most; **never hard-fail**. +4. Missing `[]` table / key → unset → next rung / default. +5. Wrong-type / empty value → ignore (warn), fall through. +6. Unknown keys/tables → silently ignored (forward-compat). +7. **Strictly read-only** — never write/create/rewrite `weft.toml`. + +**One member-local exception**: a *mutating* install/init/migrate path MAY use a +strict variant that distinguishes absent (proceed) from malformed (refuse) — +because silently treating broken-as-absent there can relocate an operator-pinned +store. Confine strictness to write paths; all discovery/runtime stays +malformed=absent. (filigree `_load_weft_filigree_table` raises; consumed only by +`filigree init`.) + +## 6. For the hub to decide at merge/bless + +1. Adopt §2.1 member-table home (filigree + loomweave both recommend). +2. Ratify rung 3 > rung 4 (shipped by loomweave). +3. `enabled`: reserved-only in v1 (filigree's vote), or pin semantics now? +4. Standardize `WEFT__URL` (filigree + loomweave shipped agreement). +5. Confirm the §3 identity/overlay split is the canonical statement of how a + member's authoritative config relates to its `weft.toml []` table + (filigree is the worked example; legis already conforms with `store_dir`). diff --git a/docs/file-traceability.md b/docs/file-traceability.md index c524f611..4aa90188 100644 --- a/docs/file-traceability.md +++ b/docs/file-traceability.md @@ -80,6 +80,19 @@ Valid `assoc_type` values: `trigger_scan` registers the file and returns `file_id` + `scan_run_id` for correlation. +External producers that post directly to `POST /api/scan-results` should send +a globally unique, non-empty `scan_run_id` when they want the run to appear in +`GET /api/scan-runs`. Omitting it or sending `""` is still accepted for +fire-and-forget findings, but those findings are intentionally excluded from +scan-run history. + +Filigree's scan-results endpoint ingests Filigree finding JSON, not raw SARIF. +If a Wardline or SARIF producer wants stable dedup and lifecycle behavior, its +adapter must map SARIF `result.partialFingerprints` or `result.fingerprints` +into each posted finding's `fingerprint` field before calling +`POST /api/scan-results`. Filigree preserves that `finding.fingerprint` through +readback, promotion, dedup, stale/fixed cleanup, and reopen-on-regress handling. + ### 5) Verify from issue and file sides Issue -> files: @@ -172,7 +185,9 @@ Checklist: Checklist: 1. Confirm dashboard API was reachable from scanner process at trigger time. -2. Check scan run history via `GET /api/scan-runs`. +2. Check scan run history via `GET /api/scan-runs`. If the producer sent an + empty `scan_run_id`, this history is intentionally empty; query findings + directly instead. 3. Query `GET /api/files/{file_id}/findings` directly to confirm ingestion. 4. If scanner sends no findings, a `202` response can be expected. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7368f19e..ef89f2d1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,7 +23,7 @@ uv add filigree ### From source ```bash -git clone https://github.com/tachyon-beep/filigree.git +git clone https://github.com/foundryside-dev/filigree.git cd filigree uv sync ``` @@ -124,7 +124,7 @@ filigree close myproj-a3f9b2e1c0 --reason="Implemented in commit abc123" ### MCP Server -The MCP server is included in the base install — no extra needed. It exposes 114 tools so agents interact with filigree without parsing CLI output. See [MCP Server Reference](mcp.md). +The MCP server is included in the base install — no extra needed. It exposes 116 tools so agents interact with filigree without parsing CLI output. See [MCP Server Reference](mcp.md). ### Web Dashboard @@ -145,7 +145,7 @@ The dashboard is included in the base install — no extra needed. ## What Next? - [CLI Reference](cli.md) — full command reference with parameter docs -- [MCP Server Reference](mcp.md) — 114 tools for agent-native interaction +- [MCP Server Reference](mcp.md) — 116 tools for agent-native interaction - [Workflow Templates](workflows.md) — state machines, packs, and field schemas - [Agent Integration](agent-integration.md) — multi-agent patterns and session resumption - [Architecture](architecture.md) — source layout, DB schema, design decisions diff --git a/docs/investigations/filigree-bcbd4d66fd-multi-owner-block-contract.md b/docs/investigations/filigree-bcbd4d66fd-multi-owner-block-contract.md new file mode 100644 index 00000000..55a57c39 --- /dev/null +++ b/docs/investigations/filigree-bcbd4d66fd-multi-owner-block-contract.md @@ -0,0 +1,214 @@ +# Investigation — filigree-bcbd4d66fd: multi-owner block contract + +**Issue C:** `inject_instructions` has no foreign-owner concept; its malformed-marker +branch can delete a sibling tool's (wardline/legis) block in a shared +CLAUDE.md/AGENTS.md. Filed P2 (feature). + +**Verdict:** Real, confirmed, and **under-prioritised** — there is a *live on-disk* +exposure today (lacuna), so this is **release-blocking for 3.0.0**, not a P2 feature. +The filigree-side fix is unilateral and was empirically verified to pass the full +suite. Legis carries the identical bug (track as a peer P0). Wardline is safe. + +Method: 8-agent investigation workflow (evidence fan-out across filigree / wardline / +legis / weft-convention → design synthesis → 3 adversarial verification lenses, one of +which *applied the fix to the real tree and ran the tests*). + +--- + +## 1. Confirmed root cause + +`_inject_instructions_locked` (`src/filigree/install.py:267-297`) identifies its block +by a hard-coded substring prefix `FILIGREE_INSTRUCTIONS_MARKER = ""` +(`install.py:119`). **No foreign-owner concept exists.** Two branches delete content +filigree does not own: + +1. **Malformed branch (the one the issue names), `install.py:285`:** + `content = content[:start] + FILIGREE_INSTRUCTIONS` — when filigree's own end marker + is absent, truncates from filigree's start marker through **EOF**. Any sibling block + physically after an unclosed filigree block is matched by neither marker and is + silently destroyed. (This truncate-to-EOF was a deliberate issue-A-era change to kill + an orphan-tail bug — any fix must preserve that idempotency invariant, + `test_malformed_block_repair_is_idempotent`.) + +2. **Well-formed replace branch, `install.py:274-276` ("Shape 2"):** + `content.find(_END_MARKER, start)` returns the *first* filigree close at-or-after + `start`. If the first filigree start is unclosed, a foreign block sits next, and a + *later* filigree block is closed, `find()` jumps to the later close and the splice + eats the sandwiched foreign block. + +**Reachable on every routine path:** `filigree install`; `doctor --fix` +(`_apply_doctor_fixes`, `admin.py:497/501`); and **automatically on SessionStart** +freshness repair (`hooks.py:178 → 200`) whenever the embedded marker hash is stale — so a +co-resident wardline block can be wiped with **no user action**. The `admin.py:451-452` +comment ("non-destructive: manages only its own marked block") is false in these branches. + +### Reproduction (by inspection — do NOT run `filigree install` against a real co-resident file) +``` +Before user preamble. + +filigree workflow body line A + +body + +``` +Filigree marker present → replace path; `find(_END_MARKER, start) == -1` (only wardline's +close follows) → malformed branch → output is `"Before user preamble.\n"` + fresh filigree +block. **The entire wardline block is gone.** + +--- + +## 2. Live exposure (why this is release-blocking) + +`~/lacuna/CLAUDE.md` **and** `~/lacuna/AGENTS.md` carry a real co-resident layout: +filigree block (lines 1–119) immediately followed by a wardline block (lines 121–123). +Today both are well-formed, so the replace path + SessionStart refresh preserve wardline. +The loss fires the moment filigree's block becomes unclosed (interrupted write, stale +format, manual edit), at which point `install.py:285` deletes lines 1–EOF including the +whole wardline block — and **filigree's own SessionStart auto-repair is the deleter.** + +This also intersects the still-open **weft campaign `weft-eb3dee402f`**: a lacuna +0-byte-CLAUDE.md emptying that no member's code reproduces. This fix removes filigree's +*foreign-deletion* vector but does **not** by itself explain a full 0-byte emptying — keep +that root-cause gate live. + +--- + +## 3. Owner survey + +| Owner | Mechanism | Missing-end handling | Foreign-safe? | +|---|---|---|---| +| **filigree** | substring `index(start)` + `find(end, start)`, splice | **truncate-to-EOF** (`install.py:285`) | **NO** — both malformed + Shape-2 | +| **wardline** | namespaced non-greedy `_FENCE_RE` (`block.py:22-25`), canonicalise own dups | **append** (no match → append) | **YES** (reachable orderings; theoretical residual: a foreign block strictly *between two* wardline fences) | +| **legis** | substring `index`+`find`, splice (`install.py:178-216`) — **identical shape to filigree** | **truncate-to-EOF** (`install.py:208`) | **NO** — malformed + Shape-2; auto-fires on drift refresh (`hooks.py:67-69`). Currently *latent* (zero legis blocks on disk) | + +De-facto convention (live in `weft/CLAUDE.md`, `weft/AGENTS.md`): namespaced HTML-comment +fences ``. The only +*normative* statement is `weft/conventions.md` **C-4** (idempotent + multi-owner + +never-empty + single-command-restore); "only-touch-your-namespace", "append-if-absent", +**bounded recovery**, and **canonicalise-own-duplicates** are unspecified. No member owns +the cross-tool contract; enforcement is per-member by weft doctrine §5/§6. + +--- + +## 4. Recommended filigree fix (unilateral; verified, with refinements folded in) + +Replace the two divergent branches with one **bounded scan**: filigree's writable region +runs from its start marker to the first of (a) its own close *if that close precedes any +foreign fence* → normal replace; (b) the next **foreign-namespace** fence → bounded +recovery; (c) EOF. Own-namespace fences are **absorbed** (never boundaries), so +duplicate/unclosed filigree blocks still collapse to one clean block (preserves the +orphan-tail invariant). The lock, symlink reject, `_atomic_write_text` + refuse-to-empty +guard, append, and create branches are untouched. **Monotonic safety property: in every +branch `bound_new ≤ bound_old`, so the fix can only *preserve* bytes the old code deleted, +never delete bytes it kept.** + +```python +import re +# Case-INSENSITIVE namespace class (refinement 1). +_INSTR_FENCE_RE = re.compile(r"`) is *not* recognised as a + boundary and is truncated-to-EOF exactly as today — the same bug, latent. *(Verifier + empirically deleted a `Wardline:instructions` block against the real shipped block.)* +2. **Guard that `instructions.md` (the filigree body) contains no `:instructions` fence + token.** The scan runs from `start+len(marker)`, i.e. across filigree's own body. If the + body ever mentions `:instructions` (a doc example, a cross-reference), `foreign` + lands inside the body and misroutes the **common** well-formed path into bounded + recovery → duplicated close marker / non-idempotent growth. Clean today (grep returns + nothing); add a test pinning it so a future body edit can't regress it. *(This is the + correct reconciliation: scanning "only after `fil_end`" would re-open the Shape-2 hole, + so guard the body instead.)* +3. **Separating newline on bounded recovery** (in the snippet above). Avoids gluing + filigree's close to a trailing foreign fence; removes a latent dependency on every + sibling's detector being non-line-anchored (wardline verified safe; legis unverified). + Cannot regress the named idempotency tests — their inputs have no foreign fence so + `bound == EOF` and `sep == ""`. +4. **Surface the stale-duplicate split-brain.** A second filigree block *beyond* a foreign + fence is intentionally left in place (foreign-safety > own-dedup) — but it is **stale, + conflicting** instructions that never update, not a harmless dup. Emit a warning when a + second filigree start marker is detected beyond `bound`, or document it normatively; + do not ship silently. + +### New tests required +- Foreign (wardline) block **survives** the named malformed repro (currently untested). +- Shape-2 sandwich: wardline block between unclosed-first + closed-later filigree survives. +- Uppercase-namespace sibling block survives (refinement 1). +- `instructions.md` contains no foreign `:instructions` fence (refinement 2). +- Bounded recovery is idempotent *with* the inserted separator (refinement 3). + +### Verified to NOT regress +Verifier applied the exact pseudocode to `src/filigree/install.py` and ran: +`tests/install/test_install.py` = **216 passed**; doctor + admin + symlink + hooks = +**332 passed**. All four named invariant tests + issue-A empty-guard/lock tests pass. +All **90** interleavings of filigree×2 / wardline×2 / legis×2 converge to one block per +tool with the preamble intact. `_END_MARKER` can never collide with a wardline/legis close +(literal string compare). Foreign-safety holds **even against buggy-sibling inputs** (does +not depend on siblings being correct). + +--- + +## 5. Cross-product coordination (do NOT defer — `feedback_no_self_deferring_cross_product_work`) + +1. **Legis peer P0 (not deferred).** Legis has the *identical* confirmed bug + (`legis/src/legis/install.py:202` Shape-2, `:208` truncate-to-EOF) and auto-fires on + drift refresh (`hooks.py:67-69`). Currently latent (no legis blocks on disk) — that + lowers priority, **not** whether it is tracked. File against legis with the same + bounded-scan fix + harden its refresh path; release-blocking label, cross-linked to this + issue. The filigree fix is genuinely unilateral and does not wait on it. + +2. **weft C-4 scorecard is self-contradictory.** `weft/conventions.md:128` certifies + filigree "conforms" and `:131` certifies legis "conforms" to the very multi-owner rule + they violate (matrix `:194`). Promoting bounded-recovery to normative **must** downgrade + both verdicts in the *same* change unit — don't certify the violators as compliant + against the new rule. weft remains the authoritative doctrine/scorecard home (live + commits today; doctrine §6 forbids shared infra, so per-member enforcement is correct) — + the memory "stale relay" note applies to runtime/rename authority, not the scorecard. + +3. **Promote the contract to normative** in `weft/conventions.md` C-4: add + only-touch-your-namespace, **bounded recovery at foreign fences**, append-on-missing-end, + canonicalise-own-duplicates, never-reorder-foreign, atomic-non-empty-write, plus the + namespace charset/case rule (refinement 1). Soften the wardline "never selects a foreign + block" line to "for all reachable orderings" (the between-two-wardline-fences residual). + +--- + +## 6. Open questions / residuals (intentional) +- A duplicate filigree block *after* a foreign block can't be canonicalised without + reaching across foreign content — left in place (foreign-safety > own-dedup), surfaced + per refinement 4. Same for an orphan *close* marker stranded in the tail. +- A "who-emptied/who-truncated" cross-tool diagnostic (C-4's single-command restore) is + unowned — out of scope here. +- CRLF: injected block stays LF even on CRLF files (pre-existing; not introduced). +- lacuna 0-byte emptying (`weft-eb3dee402f`) remains unexplained by any member's code — + keep the gate live. + +--- +*Source: investigation workflow `wf_164ce359-ad0` (8 agents). Tracker note: filigree MCP +is on schema v26 vs project v27 (SCHEMA_MISMATCH), so issue C could not be annotated +in-tracker from this session.* diff --git a/docs/mcp.md b/docs/mcp.md index b124b375..c7b5ed04 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,14 @@ # MCP Server Reference -Filigree exposes an MCP (Model Context Protocol) server so AI agents interact natively without parsing CLI output. The server provides 114 tools, 1 resource, and 1 prompt. +Filigree exposes an MCP (Model Context Protocol) server so AI agents interact natively without parsing CLI output. The server provides 116 tools, 1 resource, and 1 prompt. + +!!! note "3.0.0 tool names" + The tool names below are the subsystem-namespaced `_` names + (`issue_get`, `finding_list`, `work_start`, …). 3.0.0 **removed** the legacy + flat aliases that 2.3.0 still resolved — a call to a removed name now returns + the `NOT_FOUND` envelope. See + the [3.0.0 consumer migration guide](MIGRATION-3.0.md#1-mcp-tool-name-namespacing) + for the full old→new table. ## Contents @@ -294,10 +302,9 @@ callers can confirm the exact inserted comment without a follow-up read. Returns `by_status` (counts by literal workflow status name such as `open` or `in_progress`) and `by_category` (template categories `open`/`wip`/`done`), plus `by_type`, `ready_count`, `blocked_count`, and `total_dependencies`. The -`status_name_counts` and `status_category_counts` maps are **deprecated** exact -duplicates of `by_status` / `by_category` (filigree-17694d2db8), kept as -compatibility aliases per ADR-009 §7 and scheduled for removal in the next -major. +deprecated `status_name_counts` / `status_category_counts` maps (exact +duplicates of `by_status` / `by_category`) were **removed in 3.0.0** +(filigree-e4181ae767). Read `by_status` / `by_category`. ### Planning @@ -590,7 +597,7 @@ Returns `ListResponse[TransitionDetail]` (`{items, has_more}`), with #### `mcp_status_get` -No parameters. Returns connector health fields including `status`, `db_initialized`, `schema_compatible`, `installed_schema_version`, `database_schema_version`, `code`, `error`, `guidance`, `filigree_dir`, and `runtime`. The `runtime` object identifies the executing Python binary, resolved binary path, MCP entrypoint, module file, package root, detected venv root, and install context (`venv`, `uv_tool`, or `system_or_unknown`). This tool is safe to call in warm-but-degraded `SCHEMA_MISMATCH` mode. +No parameters. Returns connector health fields including `status`, `db_initialized`, `schema_compatible`, `installed_schema_version`, `database_schema_version`, `code`, `error`, `guidance`, `filigree_dir`, `runtime`, and `actor_verification`. The `runtime` object identifies the executing Python binary, resolved binary path, MCP entrypoint, module file, package root, detected venv root, and install context (`venv`, `uv_tool`, or `system_or_unknown`). The `actor_verification` object (`{verified, verified_actor, deferral, note}`) reports the ADR-012 actor-verification posture for this transport: MCP-stdio stamps the OS identity (`verified=true`); MCP-HTTP cannot vouch for the caller, so the `actor` argument is a self-asserted claim and `verified_actor`/`verified_author` are NULL (`verified=false`) — transport-bound identity is deferred to `filigree-81d3971467`. This tool is safe to call in warm-but-degraded `SCHEMA_MISMATCH` mode. ### Analytics @@ -628,6 +635,14 @@ No parameters. Returns connector health fields including `status`, `db_initializ | `admin_import_jsonl` | Import from JSONL | | `admin_archive_closed` | Archive old closed issues | | `admin_compact_events` | Compact event history | +| `reconciliation_debt_list` | List issues carrying reconciliation debt (governed cascade closes the Legis gate deferred) | + +#### `reconciliation_debt_list` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `limit` | integer | no | Max results (default 50) | +| `offset` | integer | no | Skip first N results | #### End-of-session cleanup @@ -648,6 +663,9 @@ agent's artifacts. evidence, or intentionally dropped. 4. Review scan scratch with `finding_list`; use `finding_promote`, `finding_dismiss`, or `finding_batch_update` before deleting file records. + Note `finding_list` defaults to `suppression='active'` — wardline-accepted + (baselined/waived/judged) findings are hidden; pass `suppression='all'` (or a + specific verdict) to review them. 5. Remove synthetic file records with `file_delete`. Prefer the default refusal mode first; use `force=true` only after associated issues/findings are handled. @@ -771,24 +789,26 @@ file deletion treat `fixed` and `false_positive` as terminal; stale ### Cross-Product Entity Associations Bind a Filigree issue to an opaque entity identifier from a sibling -product (notably Clarion — see ADR-029). Filigree never parses the +product (notably Loomweave — see ADR-029). Filigree never parses the entity-ID grammar; the binding stores opaque strings so the federation -enrich-only rule (`clarion/docs/suite/loom.md` §5) is preserved. +enrich-only rule is preserved. | Tool | Description | |------|-------------| -| `entity_association_add` | Attach a Clarion entity to a Filigree issue (idempotent on the composite key — re-attach refreshes the hash, preserves original actor) | +| `entity_association_add` | Attach an opaque external entity to a Filigree issue (idempotent on the composite key — re-attach refreshes the hash, preserves original actor) | | `entity_association_remove` | Remove the binding identified by `(issue_id, entity_id)` | | `entity_association_list` | Return the entity bindings attached to an issue (raw rows; drift comparison is the consumer's job per ADR-029 §"Decision 3") | -| `entity_association_list_by_entity` | Reverse lookup: return every issue in this project bound to a given Clarion entity (the surface Clarion's `issues_for` calls) | +| `entity_association_list_by_entity` | Reverse lookup: return every issue in this project bound to a given opaque external entity ID | +| `finding_promote_and_attach_entity` | Promote a scan finding to an issue and attach an opaque external entity binding | #### `entity_association_add` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `issue_id` | string | yes | Filigree issue ID | -| `entity_id` | string | yes | Opaque Clarion entity ID; not parsed | -| `content_hash` | string | yes | Snapshot of Clarion's current content hash for drift detection at query time | +| `entity_id` | string | yes | Opaque external entity ID; may be a `loomweave:eid:...` SEI or a legacy locator; not parsed | +| `content_hash` | string | yes | Snapshot of the caller's current content hash for drift detection at query time | +| `entity_kind` / `external_entity_kind` | string | no | Caller-supplied kind metadata; never inferred from `entity_id` | | `actor` | string | no | Actor identity recorded as `attached_by` on first attach | #### `entity_association_remove` @@ -796,7 +816,7 @@ enrich-only rule (`clarion/docs/suite/loom.md` §5) is preserved. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `issue_id` | string | yes | Filigree issue ID | -| `entity_id` | string | yes | Clarion entity ID | +| `entity_id` | string | yes | Opaque external entity ID | #### `entity_association_list` @@ -812,7 +832,8 @@ is required (or accepted). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `entity_id` | string | yes | Opaque Clarion entity ID; not parsed | +| `entity_id` | string | yes | Opaque external entity ID; not parsed | +| `current_content_hash` | string | no | Caller-supplied current hash; response `freshness_status` is `fresh`, `stale`, or `unknown` | ### Agent Context Notes @@ -955,7 +976,8 @@ scanner's `language_focus`, when selecting a pack. | `prompt` | enum | no | Bundled prompt pack (default `bug-hunt`; see `prompt_pack_list`; advisory only; requires `accepts_prompt=true` / `prompt_pack_aware=true` for non-default packs) | | `api_url` | string | no | Dashboard URL override (localhost only). Defaults to the active local Filigree dashboard. | -Response: `{status, scanner, file_path, file_id, scan_run_id, pid, api_url, api_url_source, sandbox_class, risk_summary, prompt_pack_scope, message}`. +Response: `{status, scanner, file_path, file_id, scan_run_id, pid, api_url, api_url_source, sandbox_class, risk_summary, prompt_pack_scope, file_summary, message}`. +`file_summary` is the file's current severity-bucketed findings posture (`{total_findings, open_findings, critical, high, medium, low, info}`) — a posture echo so a "triggered" response is not a vacuous run-state-only green. At trigger time it is the pre-scan posture; poll `scan_status_get` for the updated breakdown once results are ingested. If the scanner name is a bundled scanner that is not enabled in this project, the `NOT_FOUND` error includes `details.bundled=true`, `enable_with: "scanner_enable"`, `cli_enable_command`, and a hint pointing at @@ -972,8 +994,9 @@ the `NOT_FOUND` error includes `details.bundled=true`, `enable_with: Spawns one scanner process per file and returns per-file `scan_run_id`s plus a `batch_id` for correlation. The response also echoes `api_url`, -`api_url_source`, and scanner risk/sandbox metadata. Same 30s rate-limit applies -per scanner+file. +`api_url_source`, and scanner risk/sandbox metadata. Each `per_file` entry +carries a `file_summary` posture echo (severity-bucketed findings for that file). +Same 30s rate-limit applies per scanner+file. #### `scan_status_get` @@ -982,7 +1005,9 @@ per scanner+file. | `scan_run_id` | string | yes | Scan run ID returned by `scan_trigger` / `scan_trigger_batch` | | `log_lines` | integer | no | Tail size (1–500, default 50) | -Returns scan status with a live PID check and a tail of the scanner's log. +Returns scan status with a live PID check and a tail of the scanner's log, plus +a `file_summary` posture echo — the severity-bucketed findings for the run's +target file(s), reflecting post-ingest state once results are POSTed back. #### `scan_preview` @@ -1022,11 +1047,22 @@ create a linked triage observation; full responses then include 3. `prompt_pack_list` — choose an advisory review lens, if needed 4. `scan_trigger` or `scan_trigger_batch` — fire-and-forget, get `scan_run_id`(s) 5. `scan_status_get` — poll for completion / tail logs -6. Check results via `finding_list` / `finding_get` or `GET /api/loom/files/{file_id}/findings` +6. Check results via `finding_list` / `finding_get` or `GET /api/weft/files/{file_id}/findings` (note: `finding_list` defaults to active-only — pass `suppression='all'` to also see wardline-accepted findings; the `/api/weft/...` read is unfiltered by default) **Rate limiting:** Repeated triggers for the same scanner+file are rejected within a 30s cooldown window. -**Important:** Results are POSTed to the dashboard API at `/api/scan-results`, the living alias for the recommended Loom generation. Without an explicit `api_url`, scanners use the active local dashboard: ethereal mode reads `.filigree/ephemeral.port`, server mode reads the configured daemon port, and the legacy `http://localhost:8377` default is only used when no active ethereal port has been recorded. Ensure the target is reachable before triggering scans — if unreachable, results are silently lost. +**Important:** Results are POSTed to the dashboard API at `/api/scan-results`, the living alias for the recommended Weft generation. Without an explicit `api_url`, scanners use the active local dashboard: ethereal mode reads `.filigree/ephemeral.port`, server mode reads the configured daemon port, and the legacy `http://localhost:8377` default is only used when no active ethereal port has been recorded. Ensure the target is reachable before triggering scans — if unreachable, results are silently lost. + +External scanner producers should include a globally unique, non-empty +`scan_run_id` in scan-results POSTs when they want `GET /api/scan-runs` +history. An omitted or empty `scan_run_id` is accepted for fire-and-forget +findings, but those findings are intentionally excluded from scan-run history. + +Filigree does not parse SARIF on the scan-results endpoint. Wardline/SARIF +adapters must map SARIF `partialFingerprints` or `fingerprints` into each +posted finding's `fingerprint` field before POSTing. Filigree preserves that +`finding.fingerprint` through readback, promote-by-fingerprint, dedup, stale +cleanup, and reopen-on-regress lifecycle transitions. **Scanner registration:** Use `scanner_available_list`, `scanner_enable`, and `scanner_disable` from MCP, or `filigree scanner available`, `filigree scanner enable `, and `filigree scanner disable ` from the CLI. Bundled scanners call installed `filigree-scanner-*` entrypoints, so projects do not need copied runner scripts. Custom scanners can still be added as TOML files under `.filigree/scanners/`. Custom scanners that declare `{prompt}` in their args template are expected to honor that prompt value themselves. diff --git a/docs/plans/2026-05-17-2.1-db-issues-hardening-design.md b/docs/plans/2026-05-17-2.1-db-issues-hardening-design.md index f556f7ad..f9cfb543 100644 --- a/docs/plans/2026-05-17-2.1-db-issues-hardening-design.md +++ b/docs/plans/2026-05-17-2.1-db-issues-hardening-design.md @@ -898,7 +898,7 @@ Dependencies: solution-design / systems-thinking / python-engineering / coverage-gap / threat-analysis / embedded-database / silent-failure agents on `db_issues.py`. -- PR #41 (https://github.com/tachyon-beep/filigree/pull/41) — +- PR #41 (https://github.com/foundryside-dev/filigree/pull/41) — 2.0.4 worktree hardening. Out-of-scope items pulled forward to §5.7, §6.1, §6.2. - ADR-002 (named API generations) — defines the 2.0 wire contract this diff --git a/docs/plans/2026-05-17-filigree-planning-deprecation.md b/docs/plans/2026-05-17-filigree-planning-deprecation.md index 158387c1..5a0caa07 100644 --- a/docs/plans/2026-05-17-filigree-planning-deprecation.md +++ b/docs/plans/2026-05-17-filigree-planning-deprecation.md @@ -1,6 +1,18 @@ # Filigree Planning-Pack Deprecation Plan **Status:** Draft (2026-05-17) + +> **Federation status (added 2026-06-05).** This plan depends on Shuttle and on +> the `loom://`/`shuttle://` URI scheme, both of whose federation status is now +> tracked authoritatively at the Loom hub. Per `~/loom/uri-scheme.md`: the +> `loom://` scheme is **formally closed** (superseded by SEI, +> `~/loom/sei-standard.md`), and the `shuttle://` milestone-reference convenience +> is **unresolved and low-stakes** because **Shuttle is a roadmap thought-bubble +> with no repo** (`~/loom/doctrine.md`, `~/loom/members/shuttle.md`) — so this +> deprecation has no concrete migration target today. The plan is retained as a +> design record; do not execute its `shuttle://`-dependent migration until a +> change-execution authority actually exists. + **Scope:** Sequencing and migration plan for retiring filigree's `planning` pack types when Shuttle assumes ownership of planning **Sibling documents:** - `2026-05-17-shuttle-design.md` — Shuttle architecture (the new home for planning) diff --git a/docs/plans/2026-05-17-loom-uri-spec.md b/docs/plans/2026-05-17-loom-uri-spec.md index 822b1449..20d61692 100644 --- a/docs/plans/2026-05-17-loom-uri-spec.md +++ b/docs/plans/2026-05-17-loom-uri-spec.md @@ -1,6 +1,19 @@ # Loom URI Scheme Specification -**Status:** Draft (2026-05-17) +**Status:** Draft (2026-05-17) — **superseded; see status note below.** + +> **Federation status (added 2026-06-05).** The federation-wide status of this +> `loom://` URI scheme is now tracked authoritatively at `~/loom/uri-scheme.md`. +> In short: the `loom://` registry + `/api/loom/multi-fetch` apparatus was +> **never implemented and is formally closed** — the stable cross-tool identity +> it reached for is now delivered by **SEI** (`~/loom/sei-standard.md`), which +> lists this scheme as out of scope. The thin `shuttle://…` milestone-reference +> convenience (used by the planning-deprecation plan) is **unresolved and +> low-stakes** because **Shuttle is a roadmap thought-bubble with no repo** — +> there is nothing for a `shuttle://` URI to resolve to. This spec is retained +> for historical context; do not build on `loom://` or `shuttle://`. See +> `~/loom/uri-scheme.md` and `~/loom/contracts-index.md`. + **Scope:** Canonical form, registration, resolution, and authorization for Loom URIs — the cross-component reference primitive in the Loom federation **Sibling documents:** - `2026-05-17-shuttle-design.md` — Shuttle (consumer) diff --git a/docs/plans/2026-05-17-shuttle-design.md b/docs/plans/2026-05-17-shuttle-design.md index 0fc36033..3bed75a0 100644 --- a/docs/plans/2026-05-17-shuttle-design.md +++ b/docs/plans/2026-05-17-shuttle-design.md @@ -43,6 +43,14 @@ affordances, cross-step synthesis at fleet scale). Trying to bolt those onto filigree would distort filigree's role; building them as Shuttle, a federation peer, keeps each component's responsibility envelope clean. +> **Roster/status pointer (added 2026-06-05).** The four-component framing in +> this document is stale and Shuttle-centric. The **authoritative federation +> roster and axiom now live at `~/loom/doctrine.md`**: 5 realized members +> (Clarion, Filigree, Wardline, Legis, Charter), and **Shuttle is a roadmap +> thought-bubble with no repo** — this very design was never built (see +> `~/loom/members/shuttle.md`). Read this as a historical design sketch, not +> current membership. + **Federation reality check.** As of 2026-05-17: - Filigree's `/api/loom/*` surface exists (issues, files, analytics, diff --git a/docs/plans/2026-06-05-3.0.0-release-plan.md b/docs/plans/2026-06-05-3.0.0-release-plan.md new file mode 100644 index 00000000..404fd563 --- /dev/null +++ b/docs/plans/2026-06-05-3.0.0-release-plan.md @@ -0,0 +1,105 @@ +# 3.0.0 Release Plan & File Manifest + +**Branch:** `release/3.0.0` (off `origin/main` @ `d057645`) +**Type:** Major (SemVer-major boundary) +**Status:** Foundation landed; breaking-work bundle in progress +**Authored:** 2026-06-05 + +--- + +## 1. Why a major + +2.x deferred a cluster of **breaking wire-surface changes** because shipping them +mid-2.x would break federation consumers (Clarion / Wardline / Shuttle) without a +coordinated migration window. 3.0.0 opens that boundary. The release **bundles** +those breaking items (per the maintainer decision "bundle breaking work first"), +so the PR is not release-ready until the breaking checklist in §4 is complete. + +## 2. What has already landed on `release/3.0.0` + +Merged from the outstanding branches + one re-implementation: + +| Source | Nature | Result | +|--------|--------|--------| +| `codex/loom-feedback-tickets` | feature | loom close-on-fixed cascade via `scanned_paths`; ADR-029 entity-association polish; doctor contract notes | +| `bolt-opt-issue-batch-query` | perf | removed redundant open-blockers query in batch fetch | +| `palette-aria-labels` | a11y | ARIA labels on icon-only dashboard buttons (duplicate commit collapsed by merge) | +| `dependabot/uv/starlette-1.0.1` | deps | starlette 0.52.1 → 1.0.1 (major); full suite green | +| `fix/anchor-discovery-hardening` | security | **not merged** (190 commits stale). Re-implemented its one genuinely-missing control — bidirectional worktree back-pointer verification — fresh against current code. Its other hardening already landed (refactored `_read_gitdir_pointer`); its `find_filigree_anchor` import predates the `find_filigree_conf` rename. | + +**Excluded:** `gh-pages` (deploy artifact), `dependabot` is the only lock-only change. +CI gate green: ruff ✓ · ruff format ✓ · mypy (105 files) ✓ · pytest (full, 4 skips) ✓ · biome JS ✓. + +## 3. File manifest + +### 3.1 ARCHIVE (move / mark complete — no longer current) + +| Path | Action | Reason | +|------|--------|--------| +| `docs/plans/2026-04-26-2.0-phase-d-mcp-forward-migration.md` | → `docs/plans/completed/` | 2.0 migration, shipped | +| `docs/plans/2026-04-28-2.0-phase-e-cli-forward-migration.md` | → `docs/plans/completed/` | 2.0 migration, shipped | +| `docs/plans/2026-04-28-2.0-phase-f-release-readiness.md` | → `docs/plans/completed/` | 2.0 release readiness, shipped | +| `docs/plans/2026-05-18-2.1.0-release-prep.md` | → `docs/plans/completed/` | 2.1.0 shipped (v2.1.0/v2.1.1 tags) | +| stale remote branches (`release/2.0.x`, `1.6.0`, `v1.4.x`, `feat/*` merged) | delete after release | branch hygiene; not files | + +### 3.2 RENAME (breaking surface renames — the heart of the major) + +| Surface | From → To | Files | Ticket | +|---------|-----------|-------|--------| +| MCP tool names (~115) | `finding_list` → `filigree_finding_list`, etc. (subsystem prefix; dual-resolution transition window) | `src/filigree/mcp_tools/*.py`, `src/filigree/mcp_server.py`, tool registry/metadata, `tests/mcp/*`, `docs/mcp.md`, served prose | `filigree-7771610917` (ADR-016) | +| Entity-association field | `clarion_entity_id` → `entity_id` / `external_entity_id` (retain `clarion_entity_id` as documented compat alias) | `db_entity_associations.py`, `db_schema.py`, `db_events.py`, `db_files.py`, `dashboard_routes/entities.py`, `generations/loom/types.py`, `types/core.py`, `types/events.py`, `migrations.py`, `static/js/views/detail.js` | `filigree-45d76e71bb` | +| Stats keys | drop `status_name_counts` / `status_category_counts` (→ `by_status` / `by_category`) | `db_meta.py`, `types/planning.py`, `mcp_tools/meta.py`, HTTP schema | `filigree-e4181ae767` | +| Transition flag | `backward: bool` → `TransitionMode` enum | `db_issues.py`, `db_base.py`, workflow templates, `InvalidTransitionError` | `filigree-9b4bb6e52e` | + +### 3.3 UPDATE + +| Path | Change | Status | +|------|--------|--------| +| `pyproject.toml` | `version = "3.0.0"` | ✅ done | +| `CHANGELOG.md` | `[3.0.0]` section + major preamble | ✅ done (landed work); breaking entries pending | +| `src/filigree/db_schema.py` | `CURRENT_SCHEMA_VERSION` 23 → 24 **if** a schema change lands (actor verified column; entity_id column rename) | pending §4 | +| `src/filigree/migrations.py` | new `_migrate_v23_to_v24` for any schema change | pending §4 | +| `docs/UPGRADING.md` | 2.3 → 3.0 upgrade steps | pending | +| `docs/MIGRATION.md` | consumer migration narrative | pending | +| `docs/SCHEMA_MIGRATIONS.md` | record v24 if added | pending §4 | +| `docs/mcp.md`, `docs/api-reference.md` | new tool names + alias window | pending §4 | +| `docs/architecture/decisions/ADR-016-mcp-tool-namespacing.md` | status → Accepted/Implemented | pending §4 | +| `CLAUDE.md` / `AGENTS.md` | instruction stamp → v3.0.0 | ⚠️ see §5 (now untracked) | + +### 3.4 CREATE + +| Path | Purpose | +|------|---------| +| `docs/architecture/decisions/ADR-029-entity-association-opacity.md` | **cited by commits + CLAUDE.md but does not exist** — backfill the ADR for the opaque/de-Clarionized entity-association model | +| `docs/architecture/decisions/ADR-019-transition-mode-enum.md` | new — TransitionMode design | +| `docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md` | new (or extend ADR-012) — verified vs claimed actor | +| `docs/MIGRATION-3.0.md` (or §in MIGRATION.md) | **consumer migration guide**: old→new MCP tool-name mapping table, `get_stats` key migration, `entity_id` rename, transition-mode change | +| `docs/plans/2026-06-05-3.0.0-release-plan.md` | this document | +| migration test fixtures / contract fixtures under `tests/fixtures/contracts/` | regenerate for renamed surfaces | + +## 4. Breaking-work checklist (must land before PR is release-ready) + +- [ ] `filigree-7771610917` — MCP tool namespacing (~115 tools) + dual-resolution alias window — **largest item; ripples to all consumers** (ADR-016) +- [ ] `filigree-45d76e71bb` — De-Clarionize entity-association naming (compat alias) *(in_progress; partially merged via codex)* +- [ ] `filigree-e4181ae767` — Remove deprecated `get_stats` alias keys +- [ ] `filigree-9b4bb6e52e` — `TransitionMode` enum replacing `backward` bool +- [ ] `filigree-d25e75cebf` — `safe_message` parity for claim/transition errors +- [ ] `filigree-81d3971467` — Transport-bound actor identity verification *(event-schema change → likely the v24 bump)* + +Parent epics: `filigree-18bd3b8c98` (Toolkit DX), `filigree-e51a7c2263`. + +Each is a feature in its own right (TDD + full gate). The MCP namespacing item +needs a **published consumer-migration window** with both names resolving, not a +hard cutover. Sequence suggestion: schema-bearing change (`81d3971467`) → +de-Clarionize (`45d76e71bb`) → stats keys (`e4181ae767`) → TransitionMode +(`9b4bb6e52e`) → safe_message (`d25e75cebf`) → MCP namespacing (`7771610917`, +last, as the broadest wire change). + +## 5. Open item for the maintainer — instruction-file untracking + +During this session the filigree instruction-manager rewrote `.gitignore` and +**untracked** `AGENTS.md` / `CLAUDE.md` (now gitignored as "untracked +per-checkout"; files remain on disk, deletion staged). This is a repo-policy +change unrelated to 3.0.0 and was **deliberately kept out of the release +commits**. Decide separately whether to adopt it (commit the `.gitignore` +rewrite + `git rm --cached` of the instruction files) or revert it. diff --git a/docs/plans/2026-06-05-clarion-loomweave-loom-weft-rebrand-inventory.md b/docs/plans/2026-06-05-clarion-loomweave-loom-weft-rebrand-inventory.md new file mode 100644 index 00000000..6b26fdca --- /dev/null +++ b/docs/plans/2026-06-05-clarion-loomweave-loom-weft-rebrand-inventory.md @@ -0,0 +1,287 @@ +# Rebrand rub-point inventory: Clarion → Loomweave, Loom → Weft + +**Date:** 2026-06-05 +**Branch:** release/3.0.0 +**Status:** Inventory only — *no renaming performed in this pass.* +**Decision (owner, 2026-06-05):** Ride the 3.0.0 breaking-change train as a **hard +wire-break**. No compatibility aliases for the new contracts. Consumers +(Loomweave, Wardline, Shuttle, and every deployment env) cut over at 3.0.0. + +**Tracked as epic `filigree-1d08ffb493`** with tier-ordered subtasks G0 / T2A / +T2B / T0 / T1 / T0b / T3 / Parked (see §7). + +### Names table (2026-06-05) — mind the provenance column + +⚠️ **G0 CLOSED 2026-06-06** (`filigree-23709c5975`). The hub published the +renamed roster; the rows below are updated to **CONFIRMED** or **RESIDUAL**. +`CONFIRMED` rows are locked — safe to flip in code (see the execution plan +`docs/plans/2026-06-06-loomweave-weft-rebrand-execution-plan.md`). `RESIDUAL` +rows are NOT yet locked and are carved to the residual gate +`filigree-73a2d91f5c` — **do not flip them in code.** A wrong-but-confident +contract name is worse than a blank. + +| Surface | Old | New | Status | +|---------|-----|-----|--------| +| Sibling product | `Clarion` | `Loomweave` | **CONFIRMED** (user, original brief) | +| Federation | `Loom` | `Weft` | **CONFIRMED** (user, original brief) | +| HTTP generation | `/api/loom/*`, gen `"loom"` | `/api/weft/*`, gen `"weft"` | **CONFIRMED** (relay) | +| Entity key | `clarion_entity_id` | `loomweave_entity_id` | **CONFIRMED** (relay) — see opaqueness caveat below | +| Finding rule-code prefix | `CLA-` | `LMWV-` | **CONFIRMED** (relay) | +| Token env var | `CLARION_LOOM_TOKEN` | `WEFT_TOKEN` | **CONFIRMED** (hub G0, 2026-06-06) — hub locked the **short form**, NOT the earlier proposed `LOOMWEAVE_WEFT_TOKEN` | +| Token audience | `"loom"` | `"weft"` | **CONFIRMED** (hub G0) — security-sensitive; re-issue tokens at deploy | +| Error codes | `CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC` | `LOOMWEAVE_*` | **RESIDUAL — `filigree-73a2d91f5c`** (hub silent; not in this plan) | +| SEI value prefix | `clarion:eid:` | `loomweave:eid:` | **CONFIRMED** (hub G0) — sibling-produced; must match emitter | +| registry_backend literal / section | `"clarion"` / `[clarion]` | `"loomweave"` / `[loomweave]` | **CONFIRMED** (hub G0) | +| URI scheme | `loom://` | `weft://` | **RESIDUAL — `filigree-73a2d91f5c`** (not locked; `loom://` stays) | +| Legis (governance member) | `Legis` | **unknown** | **RESIDUAL — `filigree-73a2d91f5c`** (hub roster still unpublished) | + +Two coordination reversals folded in (both CONFIRMED via the relay): +- **Wire layer renames NOW.** An earlier posture kept the `loom` wire layer until + lockstep; "it's all changing" reverses that — `/api/loom/*` and the generation + token flip in T1. (The sibling's `X-Loom-*` headers / `loom_*` JSON fields are + *Loomweave's* surface — **Filigree has none**.) +- **Entity key is rebranded, not de-branded.** `clarion_entity_id` → + `loomweave_entity_id` (the field Loomweave's `filigree.rs` deserializes), which + **overrides** in-flight task `filigree-45d76e71bb` (planned de-brand to canonical + `entity_id` + aliases). + +> 🔶 **Opaqueness caveat (resolve in G0, before T0 executes).** Task +> `filigree-45d76e71bb` was filed on first-loom-test feedback to make the key +> *opaque* — `entity_id`/`external_entity_id`, "stop implying a brand-specific +> locator." Renaming to `loomweave_entity_id` **re-introduces a brand**, arguably +> re-creating the exact problem. The relay confirmed `loomweave_entity_id`, but if +> *opaqueness* was the real intent the column name changes again and **T0's +> migration differs**. Cheap to confirm now; expensive to re-migrate later. + +Two coordination reversals folded in: +- **Wire layer renames NOW.** An earlier posture kept the `loom` wire layer until + lockstep; "it's all changing" reverses that — `/api/loom/*` and the generation + token flip in T1. (The sibling's `X-Loom-*` headers / `loom_*` JSON fields are + *Loomweave's* surface — **Filigree has none**.) +- **Entity key is rebranded, not de-branded.** `clarion_entity_id` → + `loomweave_entity_id` (the field Loomweave's `filigree.rs` deserializes), which + **overrides** in-flight task `filigree-45d76e71bb` (planned de-brand to canonical + `entity_id` + aliases). Canonical `entity_id` stays primary; the branded compat + alias just changes brand. + +--- + +## 1. Scope: two independent rename axes + +| Axis | Old | New | What it names | Filigree blast radius | +|------|-----|-----|---------------|------------------------| +| **A** | **Clarion** | **Loomweave** | The sibling *product* — code-entity registry backend, capability/SEI provider | 131 files | +| **B** | **Loom** | **Weft** | The *federation* — and, inside Filigree, the **named API generation** (`/api/loom/*`, `generations/loom/`) and the `loom://` URI scheme | 130 files | + +These collide in exactly one identifier — the federation token env var +`CLARION_LOOM_TOKEN` — which carries **both** brands and is **deployment-set**. + +> **"It's all changing."** Treat the *entire* federation contract set as +> in-flight — no sibling is a stable anchor. A third member, **Legis** (the +> governance / closure-gate provider: `legis_client.py`, `governance.py`, +> `LEGIS_URL`, `LEGIS_API_TOKEN`, endpoint `{LEGIS_URL}/filigree/issues/{id}/closure-gate`), +> is also in scope and presumably rebranding — **its new name is not yet known +> to Filigree** (only Clarion→Loomweave and Loom→Weft are fixed). See §6. + +> ⚠️ **Execution caveat (bake into the migration ticket):** `clarion` is a safe, +> distinctive token — global replace is low-risk. **`loom` is NOT** — it is a +> substring of `bloom`, `gloom`, and appears in prose; and it is the *harder* +> axis because it doubles as the API-generation name. **Do not `sed` "loom" +> blind.** Axis B must be done by identifier, not by text. + +--- + +## 2. Classification model + +Every item is tagged on **two** axes, because "where the code lives" does not +tell you whether Filigree can rename it alone: + +- **Ownership** — `FILIGREE` (rename freely) · `JOINT` (lockstep contract with a + sibling) · `SIBLING` (value is *produced* by Loomweave; Filigree must match + whatever it emits). +- **Substrate** — `CODE` (source identifiers) · `WIRE` (HTTP path / header / + env / error code / audience) · `DATA` (lives in deployed DBs or config files; + find-replace won't touch it and a blind rename breaks reads). + +The tiers below are ordered by **coordination cost**, highest first. + +--- + +## Tier 0 — DATA in flight: stored values & deployed config (highest risk) + +These do **not** live (only) in source. A source rename alone breaks reads of +existing rows / configs. Each needs a **migration**, and Tier-0 items tagged +`SIBLING`/`JOINT` also need the sibling to agree on the new value. + +| Item | Location | Old → New | Own. | Notes / required work | +|------|----------|-----------|------|------------------------| +| **SEI value prefix** | `sei_backfill.py:56` `SEI_PREFIX = "clarion:eid:"`; checked in `registry.py:168/175`, `sei_backfill.py` (many), surfaced in `instructions.md:80`, `cli_commands/sei.py:34` | `clarion:eid:` → `loomweave:eid:` | **SIBLING** | Prefix is **produced by Loomweave** and **stored verbatim** in `entity_associations` values. Filigree's constant must match the sibling's emitter. Existing rows already carry `clarion:eid:` → needs a data migration (rewrite prefix) *or* a dual-accept read window. Hard-break decision ⇒ migrate rows + cut emitter together. **Coordinate with SEI-conformance work** (ADR-017, `project_sei_conformance`). | +| **`registry_backend` config value** | literal `"clarion"` in `core.py:690/733/781/795`, `cli_commands/files.py:797` (`--to` Choice), `admin.py:599`, `sei_backfill.py:158`; `VALID_REGISTRY_BACKENDS` set | `registry_backend = "clarion"` → `"loomweave"` | **JOINT** | Lives in deployed `.filigree.conf`. Any project on the Clarion backend has this literal on disk. Needs config migration + `VALID_REGISTRY_BACKENDS` update. The `migrate-registry --to clarion` CLI choice renames too. | +| **`[clarion]` config section + keys** | `core.py:781` (`"clarion" not in raw`), `_validate_registry_settings`; keys `base_url`, `token_env` | `[clarion]` → `[loomweave]` | **JOINT** | TOML section in deployed config. Migrate alongside the backend literal. | +| **`clarion_entity_id` column + emitted key** | `migrations.py:640/644/647/748/764`, `db_entity_associations.py:5/6/46/51-52` (projection emits both `entity_id` + `clarion_entity_id`) | `clarion_entity_id` → **`loomweave_entity_id`** | **JOINT** | Physical SQLite column **and** the wire compat key Loomweave's `filigree.rs` deserializes. **Resolved: rebrand, not de-brand** — this **overrides** in-flight `filigree-45d76e71bb` (which targeted canonical `entity_id` + aliases). Canonical `entity_id` stays primary; the branded alias changes brand. | +| **Stored finding `rule_id` prefix** | findings rows; fixtures `tests/fixtures/contracts/{classic,loom}/scan-results.json` (`CLA-PY-UNSAFE-EVAL`) | `CLA-` → `LMWV-` | **SIBLING** | Loomweave's 184-code diagnostic namespace (`CLA-CONFIG-*`, `CLA-FACT-*`, `CLA-INFRA-*`). Filigree stores `rule_id` opaquely → reads don't break, but any Filigree-side prefix filter/grouping does, and existing rows carry `CLA-*`. Co-design rewrite with the sibling; update fixtures in T3. | +| **Legis governance signature (HMAC)** | `db_entity_associations.py:60-66/181-201` (`signature`, `signoff_seq`, v25/B1); produced by **Legis**, stored verbatim, **never verified by Filigree** (no key) | re-issued, not renamed | **SIBLING** | 🔴 **The signature is an HMAC over `{issue_id, entity_id, content_hash, signoff_seq}`.** Because it covers `entity_id`, the Tier-0 SEI-prefix rename (`clarion:eid:` → `loomweave:eid:`) **silently invalidates every stored signature** — the HMAC was cut over the *old* entity_id string. Filigree cannot re-sign (no key). **Legis must re-issue every governed binding's signature over the renamed entity_id, in lockstep with the prefix migration.** Per the user, assume the HMAC simply hasn't been re-cut yet — the stored signatures are stale-pending-reissue, an *expected transient*, not corruption. This hard-couples the SEI-prefix migration to a Legis re-sign pass; don't ship the prefix rename without it. | + +--- + +## Tier 1 — WIRE contracts (breaking; federation must cut over in lockstep) + +External, observable contract. Hard-break ⇒ no aliases; siblings & deployments +break at 3.0.0. + +| Item | Location | Old → New | Own. | Notes | +|------|----------|-----------|------|-------| +| **HTTP generation prefix** | `dashboard.py:104/559-597/936`, `generations/loom/__init__.py`, README/ROADMAP | `/api/loom/*` → `/api/weft/*` | **JOINT** | The named API generation (ADR-002). `protected_paths` list, all four `create_loom_router()` mounts, OpenAPI docs. Consumers hard-coded to `/api/loom/`. | +| **Generation name token** | `dashboard.py:564/569`, `generations/loom/adapters.py:10`, `dashboard_auth.py:53` (`rest == "loom"`) | generation `"loom"` → `"weft"` | **JOINT** | The string identifying the generation in negotiation/recommendation logic. | +| **Token AUDIENCE claim** | `dashboard_auth.py:53` `rest == "loom"`, `LIVING_FEDERATION_ALIASES`, `CLASSIC_FEDERATION_ALIASES` | audience `"loom"` → `"weft"` | **JOINT** | 🔴 **Security-sensitive.** Both sides must agree *and* **tokens must be re-issued** with the new audience. Higher risk than any code rename — a mismatch fails auth federation-wide. | +| **Federation token env var** | `registry.py:18/56` `DEFAULT_CLARION_TOKEN_ENV = "CLARION_LOOM_TOKEN"`, `core.py:50/1155/1161` | `CLARION_LOOM_TOKEN` → `LOOMWEAVE_WEFT_TOKEN` *(name TBD — carries both brands)* | **JOINT** | 🔴 **Deployment-set** — breaks every deployment env, not just code. New name is a naming decision (proposed `LOOMWEAVE_WEFT_TOKEN`). Confirm with federation hub. | +| **Error code `CLARION_REGISTRY_VERSION_MISMATCH`** | `types/api.py:464/760`, `registry_errors.py:24`, `mcp_server.py:87-89/280`, `instructions.md:103`, `SKILL.md:199`, **CLAUDE.md** | → `LOOMWEAVE_REGISTRY_VERSION_MISMATCH` | **JOINT** | In `ErrorCode` StrEnum (wire value = name). Agents switch on `code`. Documented in CLAUDE.md error-codes list. | +| **Error code `CLARION_OUT_OF_SYNC`** | `cli_commands/sei.py:47`, `instructions.md:103`, `SKILL.md:199` | → `LOOMWEAVE_OUT_OF_SYNC` | **JOINT** | Emitted as JSON `code`. | +| **Remediation command string** | `cli_commands/sei.py:47` `"remediation_command": "clarion analyze"` | `clarion analyze` → `loomweave analyze` | **SIBLING** | Hands an agent a literal sibling CLI invocation — must match Loomweave's renamed binary. | +| **Capability / api_version probe** | `registry.py:69/483-492` `EXPECTED_CLARION_API_VERSION`, `core.py:1071/1210/1257` `clarion_api_version`, capabilities URL | endpoint/header semantics | **JOINT** | The version-negotiation handshake with the registry. Endpoint path & header names need confirming against Loomweave's renamed surface. | +| **`loom://` URI scheme** | `docs/plans/2026-05-17-loom-uri-spec.md` (spec); `is_loom_scoped_path` / `LOOM_*` in code | `loom://` → `weft://` | **JOINT** | Federation-wide URI scheme. Check for stored `loom://` values (would become Tier 0). | + +--- + +## Tier 2 — CODE (Filigree-owned; rename freely, mechanical) + +No external contract. Safe to rename in one pass *for axis A*; axis B by +identifier only (substring hazard). + +**Axis B — the `generations/loom/` API-generation module → `generations/weft/`:** +- Package dir `src/filigree/generations/loom/` → `generations/weft/` + (`__init__.py`, `types.py` — 79 hits, `adapters.py` — 76 hits). +- Router factory `create_loom_router` (8) → `create_weft_router`. +- DTO types: `IssueLoom`, `SlimIssueLoom`, `IssueLoomWithFiles`, + `IssueLoomWithUnblocked`, `BlockedIssueLoom`, `FileRecordLoom`, + `FileAssocLoom`, `ScanFindingLoom`, `ScanIngestResponseLoom`, `ScannerLoom`, + `PackLoom`, `ObservationLoom`, `CommentRecordLoom`, `ChangeRecordLoom`, + `IssueEventLoom`, `TypeSummaryLoom`, `ScannerConfigLoom`, … → `*Weft`. +- Adapter fns `*_to_loom` (`issue_to_loom`, `slim_issue_to_loom`, + `scan_finding_to_loom`, `observation_to_loom`, `file_record_to_loom`, + `change_record_to_loom`, `comment_record_to_loom`, + `scan_ingest_result_to_loom`, `type_template_to_loom`, + `scanner_config_to_loom`, `blocked_issue_to_loom`, `pack_to_loom`, + `file_assoc_to_loom`, `issue_event_to_loom`, …) → `*_to_weft`. +- Heaviest call sites: `dashboard_routes/issues.py` (103), `files.py` (44), + `analytics.py` (23), `releases.py`. + +**Axis A — Clarion registry/SEI internals → Loomweave:** +- Types/classes: `ClarionRegistry`, `ClarionConfig`, `ClarionEntityId`, + `ClarionResolvedFile`, `ClarionOutOfSyncError`, `ClarionRotationBanner` → + `Loomweave*`. +- Factories/helpers: `make_clarion_entity_id` (15), `_build_clarion_registry`, + `normalize_clarion_base_url`, `_ClarionLocalFallbackRegistry`, + `probe_clarion_capabilities`, `reprobe_clarion_capabilities`, + `validate_clarion_capabilities`, `_run_initial_clarion_capability_probe`, + `_resolve_clarion_auth_token`, `_validate_clarion_token_origin`, + `require_clarion_base_url`, `skip_clarion_capability_probe`. +- Constants: `DEFAULT_CLARION_TOKEN_ENV`, `CLARION_BATCH_MAX_QUERIES`, + `EXPECTED_CLARION_API_VERSION`, `CLARION_RESOLVE_FILE_MAX_ATTEMPTS`, + `CLARION_RESOLVE_FILE_RETRY_BACKOFF_SECONDS`. +- Attrs/locals: `clarion_config`, `clarion_api_version`, `_clarion_base_url`, + `_clarion_headers`, `_clarion_timeout_seconds`, `_clarion_follow_redirects`, + `clarion_conn`, `clarion_db_path`, `clarion_instance_id`, + `clarion_instance_rotated`, `unknown_clarion_keys`, + `clarion_identity_resolve_batch_url`, `clarion_files_batch_url`, + `clarion_capabilities_url`, `clarion_file_read_url`. +- Heaviest files: `registry.py` (177), `core.py` (150), `sei_backfill.py` (55), + `db_entity_associations.py` (34), `cli_commands/files.py` (15), + `cli_commands/sei.py` (12), `mcp_tools/entities.py` (12), + `dashboard_routes/files.py` / `entities.py`, `types/core.py`, + `db_schema.py`, `install_support/doctor.py`. + +**Dashboard JS (separate biome gate, see CLAUDE.md):** +`static/js/views/detail.js`, `static/js/app.js`, `static/dashboard.html` — +`clarionRotationBanner`, generation labels. + +--- + +## Tier 3 — DOCS, agent instructions, CHANGELOG (no wire risk; do for coherence) + +| Surface | Files | +|---------|-------| +| **Agent-facing instructions** (ship in package) | `src/filigree/data/instructions.md` (Clarion/SEI/error codes), `src/filigree/skills/filigree-workflow/SKILL.md` | +| **Repo CLAUDE.md** | entity-association ADR-029 blurb, error-codes list (`CLARION_REGISTRY_VERSION_MISMATCH`), `Clarion` mentions | +| **ADRs** | ADR-002 (API generations/federation posture), ADR-014 (registry backend), ADR-017 (SEI conformance), ADR-012 (actor identity threat model) | +| **Federation docs** | `docs/federation/contracts.md`, `registry-backend-launch-runbook.md` | +| **Plans** | `loom-uri-spec.md`, `shuttle-design.md`, `2.0-federation-work-package.md`, `2.0-stage-2b-rebaseline.md`, registry-backend sequencing, planning-deprecation | +| **Top-level** | `README.md` (Loom federation framing, `/api/loom/*`), `ROADMAP.md`, `CHANGELOG.md` (70 hits — historical; rename *new* 3.0.0 entries only, leave shipped-version history) | +| **CI** | `.github/workflows/ci.yml` (15 hits — job names, env, test selectors) | +| **Tests** | 100+ files; `tests/_fakes/clarion_http.py` (the fake Clarion server), `tests/federation/test_sei_oracle_live_clarion.py`, `tests/integration/test_clarion_*`, `tests/unit/test_clarion_capabilities_probe.py`, conftests. Rename with the code they exercise. | + +> **CHANGELOG nuance:** entries under already-shipped version headings record +> history accurately — leave them. Only rename references in the +> `[Unreleased]` / `[3.0.0]` section, and add a `### Changed` (BREAKING) note +> documenting the rename. + +--- + +## 3. The cross-axis collision + +`CLARION_LOOM_TOKEN` (`registry.py:56`) is the only identifier on **both** axes: +`CLARION` (product) + `LOOM` (federation). It is the federation auth token env +var, deployment-set. Proposed `LOOMWEAVE_WEFT_TOKEN` — confirm the exact name +with the federation hub doctrine since it pairs with the audience claim (Tier 1). + +--- + +## 4. MCP tool surface — mostly already clean + +The MCP tool names are **already de-branded** (`entity_association_add`, +`finding_promote_and_attach_entity`, `entity_association_list_by_entity` — not +`clarion_*`). The legacy survives in **semantics and values** (the +`clarion:eid:` SEI prefix an `entity_id` may carry; the +`CLARION_REGISTRY_VERSION_MISMATCH` envelope `mcp_server.py` emits), not in tool +names. So the MCP *naming* axis is largely done; the MCP *value/error* axis is +Tier 0/1 above. + +--- + +## 5. Reconciliation with in-flight work + +| Existing item | Effect of rebrand | +|---------------|-------------------| +| `filigree-45d76e71bb` "De-Clarionize entity-association naming **with compatibility aliases**" | **Redirected.** The public projection already exposes `entity_id`; this task planned *aliases*. Hard-break decision (§Decision) **drops the alias requirement** — finish it as a clean column rename `clarion_entity_id` → `entity_id` with a one-shot data migration, folded into the rebrand. | +| MCP namespacing `filigree-7771610917` (`filigree_finding_list`, …) | Independent breaking wire change also riding 3.0.0. Sequence together so consumers absorb one cutover, not two. | +| `project_sei_conformance` / ADR-017 / `filigree sei-backfill` | Owns the `clarion:eid:` prefix lifecycle. The Tier-0 SEI-prefix migration **must** be co-designed here — backfill is the natural vehicle to rewrite stored prefixes. | +| `project_3_0_0_release` (de-Clarionize / TransitionMode bundle already TODO) | The rebrand is a superset of the planned "de-Clarionize" work. Re-scope that bundle to "full Clarion→Loomweave / Loom→Weft rebrand." | + +--- + +## 6. Open coordination items (cannot close unilaterally) + +1. **Loomweave** must rename in lockstep: SEI emitter prefix (`loomweave:eid:`), + capabilities endpoint/`api_version` header, CLI binary (`loomweave analyze`), + and confirm the registry-backend handshake values. +2. **Federation hub** (`~/weft` formerly `~/loom`, `doctrine.md`) must bless the + new audience claim, token env-var name, and `weft://` URI scheme **before** + tokens are re-issued. +3. **Wardline / Shuttle** consume `/api/loom/*` and the federation token — they + break at 3.0.0; notify and sequence. +4. **Token re-issuance** (audience `loom`→`weft`) is an operational step, not a + code change — schedule with the deploy. +5. **Legis re-sign pass** (Tier 0): once the SEI prefix flips, Legis must re-cut + the HMAC for every governed binding over the new `entity_id`. Until it does, + stored `signature`s are stale-by-design (Filigree never verifies them, so + reads don't break — but governance re-verification downstream would). Gate + the prefix migration on this. +6. **Legis rename (target unknown).** If Legis is also rebranding, its surface + in Filigree is a *parked* rub point — `legis_client.py`, `governance.py`, + env `LEGIS_URL` / `LEGIS_API_TOKEN`, type names `LegisGateResult` / + `LegisGateStatus`, and the `{LEGIS_URL}/filigree/issues/{id}/closure-gate` + path. **Cannot execute without Legis's new name** — block on the hub + publishing the full renamed roster. + +--- + +## 7. Suggested execution order (when greenlit — not this pass) + +1. Lock the **names** (env var, audience, error codes, URI scheme, SEI prefix) + with the federation hub + siblings. Nothing else can start safely first. +2. Tier 2 axis A (Clarion→Loomweave code) — mechanical, isolated, low risk. +3. Tier 2 axis B (`generations/loom`→`weft`) — **by identifier, never `sed`**. +4. Tier 0 data migrations (column, SEI prefix, config literal/section) + tests. +5. Tier 1 wire flip + Tier 3 docs/CHANGELOG, in the same 3.0.0 cut as MCP + namespacing. +6. Re-issue tokens; notify siblings; ship. diff --git a/docs/plans/2026-06-05-governed-cascade-close-design-a.md b/docs/plans/2026-06-05-governed-cascade-close-design-a.md new file mode 100644 index 00000000..fdecba86 --- /dev/null +++ b/docs/plans/2026-06-05-governed-cascade-close-design-a.md @@ -0,0 +1,527 @@ +# Governed cascade close (Design A) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **REQUIRED SUB-SKILL:** Use superpowers:test-driven-development for every task. + +**Goal:** Make the finding→issue cascade respect the Legis closure gate for governed issues (PR #52 "Legis H-02"), using **Design A** — consult the gate in the existing **post-commit** cascade and close only if Legis allows; otherwise fail closed and record reconciliation debt. + +**Design record:** `docs/superpowers/specs/2026-06-05-governed-cascade-close.md` (decision = A; Design B specified there as the deferred zero-network fallback). **Read it first** — it explains why the review's "A is invasive" premise was wrong (the close cascade is already post-commit) and why B is retained. + +**Architecture:** The close cascade runs post-commit, outside any transaction, in two call sites — scan ingest (`db_files.py:1519-1544`) and retention/age-out (`clean_stale_findings`, `db_files.py:2069-2075`). Both delegate per-issue to `_close_issue_for_fixed_finding` (`:1922`) → `FindingIssueCascadeService.close_fixed_finding` (`finding_issue_cascade.py:73`). The gate goes in `close_fixed_finding`; both call sites get it for free. `evaluate_closure_gate` (`governance.py:72`) already short-circuits cheaply for ungoverned/unconfigured issues (no network), so only governed issues in a Legis-configured deployment hit the network. + +**Tech Stack:** Python 3.11+, SQLite, pytest, the existing `governance.py` / `legis_client.py` / `finding_issue_cascade.py`. + +**Prerequisites:** +- On branch `release/3.0.0` (do not create/switch branches without owner approval). +- Baseline green: `uv run pytest --tb=short`. +- The Legis test stub `tests/_fakes/legis_http.py` (`legis_stub`) and the `check_closure_gate` monkeypatch indirection (`governance.py:67`) are the test seams — `governance.check_closure_gate` is the cleanest thing to monkeypatch. + +**Not in this plan:** a *retry/sweep* verb for deferred closes (3.1.0 follow-up). The merge bar is: gate enforced + debt recorded idempotently + debt **listable** (Task 4). + +--- + +## Task ordering + +| # | Task | Depends on | +|---|------|------------| +| 1 | Idempotent reconciliation-debt write | — | +| 2 | Gate `close_fixed_finding` (Design A core) | 1 | +| 3 | Batch short-circuit (latency mitigation) | 2 | +| 4 | Reconciliation-debt list surface (B5) | 1 | + +--- + +## Task 1: Make the reconciliation-debt write idempotent + +**Files:** +- Modify: `src/filigree/finding_issue_cascade.py:47-66` (`record_reconciliation_debt_comment`) +- Test: the cascade test module (`grep -rl "record_reconciliation_debt_comment\|reconciliation" tests/`) + +**Context:** Under Design A, a governed issue that Legis BLOCKS (or that is unreachable) is re-evaluated on every scan ingest and every age-out sweep. `record_reconciliation_debt_comment` is a plain `INSERT` (`:58-60`), so without a guard it appends a duplicate debt comment every run, drowning Task 4's list surface in noise. + +**Step 1: Write the failing test** + +```python +def test_reconciliation_debt_comment_is_idempotent(db) -> None: + """Recording the same reconciliation debt twice leaves a single comment.""" + from filigree.finding_issue_cascade import record_reconciliation_debt_comment, RECONCILIATION_DEBT_ACTOR + + issue_id = db.create_issue(type="task", title="governed").id + text = "Finding f1 fixed but issue blocked by Legis" + record_reconciliation_debt_comment(db.conn, issue_id, text) + record_reconciliation_debt_comment(db.conn, issue_id, text) + + rows = db.conn.execute( + "SELECT COUNT(*) AS n FROM comments WHERE issue_id = ? AND author = ?", + (issue_id, RECONCILIATION_DEBT_ACTOR), + ).fetchone() + assert rows["n"] == 1 +``` + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k reconciliation_debt_comment_is_idempotent -v` + +Expected: FAIL — `n == 2` (two identical comments inserted). + +**Step 3: Implement — guard against an existing identical debt row** + +In `record_reconciliation_debt_comment`, before the INSERT, skip if an identical debt comment already exists for this issue: + +```python + full_text = f"{RECONCILIATION_DEBT_PREFIX} {text}" + existing = conn.execute( + "SELECT 1 FROM comments WHERE issue_id = ? AND author = ? AND text = ? LIMIT 1", + (issue_id, actor, full_text), + ).fetchone() + if existing is not None: + return + conn.execute( + "INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)", + (issue_id, actor, full_text, _now_iso()), + ) + conn.commit() +``` + +(Keep the existing `try/except sqlite3.Error` wrapper and the ADR-012 comment.) + +**Why this shape:** matches Task 4's discriminator (`author = RECONCILIATION_DEBT_ACTOR`) and dedups on the exact debt text, so a *different* debt reason on the same issue still records. No new index needed at 3.0.0 volumes (Task 4 documents the scan cost). + +**Step 4: Run to verify pass** + +Run: `uv run pytest -v` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/filigree/finding_issue_cascade.py tests/ +git commit -m "fix(cascade): make reconciliation-debt write idempotent + +Design A re-evaluates a blocked governed issue every ingest/sweep; the plain +INSERT would append a duplicate debt comment each run. Skip when an identical +(issue_id, author, text) debt comment already exists." +``` + +**Definition of Done:** +- [ ] Duplicate-suppression guard added +- [ ] Different-reason debt on the same issue still records (add a second assertion if useful) +- [ ] Idempotency test passes +- [ ] Committed + +--- + +## Task 2: Gate `close_fixed_finding` on the closure gate (Design A core) + +**Files:** +- Modify: `src/filigree/finding_issue_cascade.py` (`FindingIssueCascadeStore` Protocol + `close_fixed_finding`) +- Test: the cascade test module + +**Context:** `close_fixed_finding` (`:73`) currently calls `_close_issue_for_fixed_finding_tx` directly. Insert the gate before the close. `evaluate_closure_gate(reader, issue_id)` needs a reader with `list_entity_associations`; `FiligreeDB` has it, but the `FindingIssueCascadeStore` Protocol (`:26-44`) does not declare it — add it so the structural type matches `governance._AssocReader`. + +**Step 1: Write the failing tests** (monkeypatch `governance.check_closure_gate` to control the verdict; `evaluate_closure_gate` calls it only for governed+configured issues) + +```python +import filigree.governance as governance +from filigree.legis_client import LegisGateResult, LegisGateStatus + + +def _govern(db, issue_id, entity="ent-1"): + db.add_entity_association(issue_id, entity, "hash-v1", signature="sig", signoff_seq=1) + + +def test_governed_issue_not_closed_when_legis_blocks(db, monkeypatch) -> None: + monkeypatch.setenv("LEGIS_URL", "http://legis.invalid") # is_configured() → True + monkeypatch.setattr(governance, "check_closure_gate", + lambda _id: LegisGateResult(LegisGateStatus.BLOCKED, reason="not signed off")) + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) # helper: issue + fixed finding + _govern(db, issue_id) + + warnings: list[str] = [] + closed = db._close_issue_for_fixed_finding(finding_id, issue_id, warnings=warnings) + + assert closed is False + assert db.get_issue(issue_id).status != "closed" # stays open + assert any("not auto-closed" in w for w in warnings) # surfaced + debt = db.conn.execute( + "SELECT COUNT(*) AS n FROM comments WHERE issue_id=? AND author='filigree:reconciliation'", (issue_id,) + ).fetchone() + assert debt["n"] == 1 # debt recorded + + +def test_governed_issue_closed_when_legis_allows(db, monkeypatch) -> None: + monkeypatch.setenv("LEGIS_URL", "http://legis.invalid") + monkeypatch.setattr(governance, "check_closure_gate", + lambda _id: LegisGateResult(LegisGateStatus.ALLOWED)) + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) + _govern(db, issue_id) + + closed = db._close_issue_for_fixed_finding(finding_id, issue_id, warnings=[]) + assert closed is True + + +def test_governed_issue_fails_closed_when_legis_unreachable(db, monkeypatch) -> None: + monkeypatch.setenv("LEGIS_URL", "http://legis.invalid") + monkeypatch.setattr(governance, "check_closure_gate", + lambda _id: LegisGateResult(LegisGateStatus.UNREACHABLE, reason="timeout")) + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) + _govern(db, issue_id) + assert db._close_issue_for_fixed_finding(finding_id, issue_id, warnings=[]) is False # fail-closed + + +def test_ungoverned_issue_still_auto_closes(db, monkeypatch) -> None: + monkeypatch.setenv("LEGIS_URL", "http://legis.invalid") + # No signature attached → evaluate_closure_gate short-circuits PROCEED, no network. + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) + assert db._close_issue_for_fixed_finding(finding_id, issue_id, warnings=[]) is True + + +def test_governed_issue_closes_when_legis_unconfigured(db, monkeypatch) -> None: + monkeypatch.delenv("LEGIS_URL", raising=False) # governance OFF → PROCEED, no network + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) + _govern(db, issue_id) + assert db._close_issue_for_fixed_finding(finding_id, issue_id, warnings=[]) is True +``` + +> NOTE: build `make_resolved_finding_linked_to_issue` / `db` from the cascade test module's existing helpers (it already constructs resolved findings linked to issues for the un-gated cascade tests). Reuse them. + +**Why these tests:** they pin all five branches — blocked (no close, debt, warning), allowed (close), unreachable (fail-closed), ungoverned (close, no network), unconfigured (close, no network). The last two prove the cheap short-circuit and that A does not regress the common ungoverned path. + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k "governed_issue or ungoverned" -v` + +Expected: the blocked/unreachable tests FAIL (issue is closed today regardless of Legis); allowed/ungoverned/unconfigured pass. + +**Step 3: Implement the gate** + +Add `list_entity_associations` to the `FindingIssueCascadeStore` Protocol so the store satisfies `governance._AssocReader`: + +```python +class FindingIssueCascadeStore(Protocol): + @property + def conn(self) -> sqlite3.Connection: ... + + def get_issue(self, issue_id: str) -> Issue: ... + def _resolve_status_category(self, issue_type: str, status: str) -> StatusCategory: ... + def _close_issue_for_fixed_finding_tx(self, finding_id: str, issue_id: str) -> bool: ... + def list_entity_associations(self, issue_id: str) -> list[Any]: ... # NEW (satisfies _AssocReader) + ... +``` +(add `from typing import Any` if not already imported.) + +Gate inside `close_fixed_finding`: + +```python + def close_fixed_finding(self, finding_id: str, issue_id: str, *, warnings: list[str]) -> bool: + """Best-effort close of an issue whose linked finding just went fixed. + + Governed issues (DECISION 1A) are closed only if the Legis closure gate + allows; a blocked / unavailable / integrity verdict fails closed and is + recorded as reconciliation debt (Design A). The gate makes no network + call for ungoverned issues or when LEGIS_URL is unset. + """ + from filigree import governance + + decision = governance.evaluate_closure_gate(self.store, issue_id) + if not decision.allowed: + warning = f"governed issue {issue_id} not auto-closed by cascade: {decision.reason}" + warnings.append(warning) + record_reconciliation_debt_comment( + self.store.conn, + issue_id, + f"Finding {finding_id} was marked fixed, but the linked governed issue " + f"was not auto-closed ({decision.outcome.value}): {decision.reason}", + ) + return False + try: + return self.store._close_issue_for_fixed_finding_tx(finding_id, issue_id) + except (KeyError, ValueError, sqlite3.Error) as exc: + warning = f"cascade close of issue {issue_id} failed: {exc}" + warnings.append(warning) + record_reconciliation_debt_comment( + self.store.conn, + issue_id, + f"Finding {finding_id} was marked fixed, but the linked issue could not be cascade-closed: {warning}", + ) + return False +``` + +**Why this shape:** one gate call covers both call sites (ingest + age-out) since both route through here. The reopen cascade is deliberately untouched — **governance gates closure, not reopen** (a regressed finding reopening a governed issue needs no Legis approval). The `import governance` is function-local to avoid any import cycle with the data layer. + +**Step 4: Run to verify pass** + +Run: +```bash +uv run pytest -v +uv run pytest tests/ -k "cascade or ingest_scan or clean_stale or governance or closure_gate" -q +``` + +Expected: all PASS — including the existing un-gated cascade tests (ungoverned issues still close) and the age-out tests. + +**Step 5: Commit** + +```bash +git add src/filigree/finding_issue_cascade.py tests/ +git commit -m "fix(cascade): gate governed finding->issue auto-close on Legis (design A) + +The post-commit cascade closed governed issues with force=True, never +consulting Legis (review Legis H-02). Route close_fixed_finding through +evaluate_closure_gate: governed issues close only when Legis allows; a +blocked/unavailable/integrity verdict fails closed and records reconciliation +debt. Ungoverned/unconfigured paths short-circuit with no network call. The +reopen cascade is intentionally not gated. + +Design: docs/superpowers/specs/2026-06-05-governed-cascade-close.md" +``` + +**Definition of Done:** +- [ ] `list_entity_associations` declared on the Protocol +- [ ] Gate enforced; blocked/unavailable/integrity → no close + idempotent debt + warning +- [ ] Ungoverned and unconfigured paths still close (no network) +- [ ] Reopen cascade untouched +- [ ] Existing cascade + age-out tests green +- [ ] Committed + +--- + +## Task 3: Batch short-circuit (Legis-down latency mitigation) + +**Files:** +- Modify: `src/filigree/finding_issue_cascade.py` (add a batch method) and the two call sites in `src/filigree/db_files.py` (`:1532-1537`, `:2073-2075`) +- Test: the cascade test module + +**Context:** `legis_client` has a 5 s default timeout. With Legis down and a batch resolving *N* governed findings, the per-issue loop incurs up to *N × 5 s* of serial timeouts on the post-commit hot path. Mitigate: after the first `UNAVAILABLE` verdict, treat the rest of the batch as deferred debt **without** re-calling Legis. + +**Step 1: Write the failing test** + +```python +def test_batch_short_circuits_after_legis_unreachable(db, monkeypatch) -> None: + """Once Legis is seen unreachable, the rest of the batch is deferred to debt + without further gate calls (bounds the timeout cost to one per batch).""" + monkeypatch.setenv("LEGIS_URL", "http://legis.invalid") + calls = {"n": 0} + + def _gate(_issue_id): + calls["n"] += 1 + from filigree.legis_client import LegisGateResult, LegisGateStatus + return LegisGateResult(LegisGateStatus.UNREACHABLE, reason="timeout") + + monkeypatch.setattr(governance, "check_closure_gate", _gate) + + candidates = [] + for _ in range(3): + issue_id, finding_id = make_resolved_finding_linked_to_issue(db) + _govern(db, issue_id) + candidates.append((finding_id, issue_id)) + + warnings: list[str] = [] + db._finding_issue_cascade_service().close_resolved_findings(candidates, warnings=warnings) + + assert calls["n"] == 1 # only the first governed issue called Legis + # all three recorded debt, none closed + n = db.conn.execute("SELECT COUNT(*) AS n FROM comments WHERE author='filigree:reconciliation'").fetchone()["n"] + assert n == 3 +``` + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k batch_short_circuits -v` + +Expected: FAIL — `close_resolved_findings` does not exist yet (AttributeError). + +**Step 3: Implement the batch method** + +Add to `FindingIssueCascadeService` a batch driver that carries a `legis_down` flag and skips the gate for the remainder once an `UNAVAILABLE` verdict is seen: + +```python + def close_resolved_findings(self, candidates: list[tuple[str, str]], *, warnings: list[str]) -> list[str]: + """Gate-and-close a batch of (finding_id, issue_id). Short-circuits the + Legis gate after the first UNAVAILABLE verdict so a down/slow Legis costs + at most one timeout per batch (the rest defer to reconciliation debt).""" + from filigree import governance + from filigree.governance import GateDecision, GateOutcome + + closed: list[str] = [] + legis_down = False + for finding_id, issue_id in candidates: + if legis_down: + decision = GateDecision(GateOutcome.UNAVAILABLE, "Legis unreachable earlier in this batch") + else: + decision = governance.evaluate_closure_gate(self.store, issue_id) + if decision.outcome is GateOutcome.UNAVAILABLE: + legis_down = True + if self._apply_close_decision(finding_id, issue_id, decision, warnings=warnings): + closed.append(issue_id) + return closed +``` + +Refactor `close_fixed_finding` so the decision-application logic is shared with the batch path (extract `_apply_close_decision(finding_id, issue_id, decision, *, warnings) -> bool` holding the Step-2 close/debt body; `close_fixed_finding` becomes "evaluate gate, then `_apply_close_decision`"). Keep `close_fixed_finding` for any single-issue callers/tests. + +Rewire the two `db_files.py` call sites to the batch method: + +- Ingest (`:1532-1537`): build the filtered candidate list, then call the batch method: +```python + warnings_before_close = len(stats["warnings"]) + close_candidates = [(fid, iid) for fid, iid in sorted(resolved) if iid not in regressed_issue_ids] + closed_issue_ids = self._finding_issue_cascade_service().close_resolved_findings( + close_candidates, warnings=stats["warnings"] + ) + for warning in stats["warnings"][warnings_before_close:]: + logger.warning("finding→issue close cascade: %s", warning) +``` +- Age-out (`clean_stale_findings`, `:2071-2075`): +```python + warnings: list[str] = [] + valid = [(fid, str(iid)) for fid, iid in fixed if iid] + closed_issue_ids = self._finding_issue_cascade_service().close_resolved_findings(valid, warnings=warnings) + for warning in warnings: + logger.warning("clean_stale_findings cascade: %s", warning) +``` + +**Why this shape:** the batch driver is the natural home for cross-item state (the `legis_down` flag); both call sites already collected a list of `(finding_id, issue_id)`, so the rewire is a small simplification, not a restructure. `INTEGRITY_FAILURE` is intentionally **not** short-circuited (it is a per-issue ledger-tamper verdict, not a connectivity problem). + +**Step 4: Run to verify pass** + +Run: +```bash +uv run pytest -v +uv run pytest tests/ -k "ingest_scan or clean_stale or cascade" -q +``` + +Expected: all PASS — including the batch short-circuit and unchanged ungoverned-close behavior. + +**Step 5: Commit** + +```bash +git add src/filigree/finding_issue_cascade.py src/filigree/db_files.py tests/ +git commit -m "perf(cascade): short-circuit Legis gate after first UNAVAILABLE in a batch + +A's synchronous gate call costs up to N*5s when Legis is down and a batch +resolves N governed findings. Add a batch driver that defers the remainder to +reconciliation debt after the first UNAVAILABLE verdict, bounding the timeout +cost to one per batch. Rewire the ingest and age-out cascade loops to it." +``` + +**Definition of Done:** +- [ ] `close_resolved_findings` batch driver with `legis_down` short-circuit +- [ ] Both `db_files.py` call sites rewired +- [ ] `INTEGRITY_FAILURE` not short-circuited (per-issue) +- [ ] Short-circuit test asserts exactly one gate call for an all-governed down-Legis batch +- [ ] Existing ingest/age-out tests green +- [ ] Committed + +--- + +## Task 4: Reconciliation-debt list surface (B5) + +**Files:** +- Add: a `db_meta.py` (or `db_issues.py`) query method, e.g. `list_reconciliation_debt(limit, offset) -> list[...]` +- Add: CLI verb (`cli_commands/` — mirror an existing list verb) + MCP tool (`mcp_tools/` — mirror an existing list tool, register in `mcp_tools/tiers.py` and the rename map if applicable) +- Test: CLI test + MCP test + a DB-layer test + +**Context:** Reconciliation debt is now durable and idempotent (Tasks 1–2) but not actionable — there is no verb to find issues carrying it. Add a cross-issue read surface. **Discriminate on `author = 'filigree:reconciliation'`** (`RECONCILIATION_DEBT_ACTOR`), NOT a `LIKE '[reconciliation-debt]%'` scan on the unindexed `comments.text`. + +**Step 1: Write the failing DB-layer test** + +```python +def test_list_reconciliation_debt_returns_issues_with_debt(db) -> None: + from filigree.finding_issue_cascade import record_reconciliation_debt_comment + + with_debt = db.create_issue(type="task", title="blocked").id + without = db.create_issue(type="task", title="clean").id + record_reconciliation_debt_comment(db.conn, with_debt, "blocked by Legis") + + rows = db.list_reconciliation_debt(limit=50, offset=0) + ids = {r["issue_id"] for r in rows} + assert with_debt in ids + assert without not in ids +``` + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k list_reconciliation_debt -v` + +Expected: FAIL — `AttributeError: ... has no attribute 'list_reconciliation_debt'`. + +**Step 3: Implement the query + CLI + MCP** + +DB method (group debt comments by issue; newest first): +```python + def list_reconciliation_debt(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]: + """Issues carrying reconciliation debt (a filigree:reconciliation comment). + + Discriminates on author, not the comment-text prefix, so it does not + depend on the human-readable prefix string and does not table-scan + comments.text. + """ + from filigree.finding_issue_cascade import RECONCILIATION_DEBT_ACTOR + + rows = self.conn.execute( + """ + SELECT issue_id, COUNT(*) AS debt_count, MAX(created_at) AS latest, MAX(text) AS latest_text + FROM comments + WHERE author = ? + GROUP BY issue_id + ORDER BY latest DESC + LIMIT ? OFFSET ? + """, + (RECONCILIATION_DEBT_ACTOR, limit, offset), + ).fetchall() + return [dict(r) for r in rows] +``` + +CLI verb: mirror an existing list command in `cli_commands/` (e.g. the `ready`/`list` shape) — `filigree reconciliation-debt [--limit N] [--json]` → prints issue_id, debt_count, latest. MCP tool: mirror an existing list tool in `mcp_tools/`, register it in `mcp_tools/tiers.py` (and `mcp_tools/rename.py` if the namespaced-name discipline requires a rename row — check `tests/mcp/test_rename_map.py`'s "every tool has exactly one rename row" assertion and add one if needed). + +> NOTE: match this repo's actual list-verb/list-tool conventions (envelope `{items, has_more, next_offset?}`, pagination via `_parse_pagination`). Grep an existing list endpoint (e.g. `list_observations` → its CLI + MCP wrappers) and mirror it exactly — including the `ListResponse` envelope and the rename-map row. + +**Step 4: Run to verify pass** + +Run: +```bash +uv run pytest -v +uv run pytest tests/mcp/test_rename_map.py -q # if an MCP tool was added +``` + +Expected: all PASS. + +**Step 5: Commit** + +```bash +git add src/filigree/ tests/ +git commit -m "feat(cascade): list reconciliation debt (CLI + MCP) + +Make Design A's deferred-close debt actionable: a cross-issue read surface +listing issues that carry reconciliation debt, discriminating on +author='filigree:reconciliation' (not a comments.text prefix scan). Retry/sweep +of deferred closes is a 3.1.0 follow-up." +``` + +**Definition of Done:** +- [ ] `list_reconciliation_debt` queries by author, not text prefix +- [ ] CLI verb + MCP tool added, mirroring existing list conventions + envelope +- [ ] MCP rename-map row added if the namespacing discipline requires it (rename-map test green) +- [ ] DB/CLI/MCP tests pass +- [ ] Committed + +--- + +## Pre-merge verification (run once, after Task 4) + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +make coverage-floors +``` + +Expected: all green. If a new MCP tool was added, also confirm `tests/mcp/test_rename_map.py` and any served-prose / old-name guards pass. + +## Handoff notes + +- **Spec & decision:** `docs/superpowers/specs/2026-06-05-governed-cascade-close.md`. Design B (zero-network fallback) is fully specified there; switching A→B later is a one-branch change in `close_fixed_finding`. +- **Retry/sweep verb** (re-attempt deferred closes) is deliberately out of scope → 3.1.0. +- **Latency:** Task 3 bounds the Legis-down cost to one timeout per batch; if operators still find the synchronous gate on the ingest path unacceptable, that is the trigger to switch to Design B. +- **Correction to umbrella plan v2:** v2 (and the PR #52 review) called Design A "invasive / reorders the cascade." That was based on the false premise that the cascade is in-transaction. It is post-commit (both callers); update v2's B2 section to reflect this if it is revisited. diff --git a/docs/plans/2026-06-05-pr52-security-remediation-d2d3-independent.md b/docs/plans/2026-06-05-pr52-security-remediation-d2d3-independent.md new file mode 100644 index 00000000..ea0674fe --- /dev/null +++ b/docs/plans/2026-06-05-pr52-security-remediation-d2d3-independent.md @@ -0,0 +1,789 @@ +# PR #52 Security Remediation (D2/D3-independent slice) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **REQUIRED SUB-SKILL:** Use superpowers:test-driven-development for every task that touches code (Tasks 2–7). + +**Goal:** Land the merge-blocking and low-risk fixes from the PR #52 review that do **not** depend on the two open decisions (D2 = cascade-close policy, D3 = §4 breaking-bundle tiering), so the 3.0.0 release branch closes its known security/correctness holes while those decisions are discussed. + +**Architecture:** Seven independent, individually-committable changes against `release/3.0.0`. Each is isolable — no shared state, no ordering dependency between tasks (one trivial file-collision note on `db_meta.py`, called out in Task 5). Five are near-one-liners with test work; one (Task 7, the Legis redirect hardening) carries real design nuance and is specced in detail. + +**Tech Stack:** Python 3.11+, FastAPI/Starlette, stdlib `urllib.request`, SQLite, pytest (async via `anyio`/`pytest-asyncio`), ruff, mypy, biome (JS only — not needed here). + +**Scope boundary — explicitly NOT in this plan (blocked on decisions):** +- **B2 / B5** (governed finding→issue cascade fail-closed + reconciliation-debt list surface) — gated by **D2**. +- **T2.1** (remove `get_stats` deprecated alias keys) and the §4 deferrals (TransitionMode, `clarion_entity_id` rename, `safe_message` parity) — gated by **D3**. +- These remain in `/tmp/filigree-3.0.0-remediation-plan.md` (v2) and will be planned once D2/D3 are settled. + +**Prerequisites:** +- On branch `release/3.0.0` (do **not** create or switch branches without owner approval). +- `source .venv/bin/activate` (the dashboard venv is already active in the maintainer's session). +- Baseline green: `uv run pytest --tb=short` passes before starting. + +**Decision already taken (D1):** the signatureless-reattach un-govern (review M-01) is a **defect**. Task 6 fixes it. See the risk note in Task 6 about Clarion drift-refresh — flag to the owner, do not silently change that path. + +**Commit attribution:** commit messages below use a placeholder co-author line; the executing agent substitutes its own identity. Per repo policy, `release/3.0.0` is the integration branch for this work — commit directly to it (no new branches without owner approval). + +--- + +## Task ordering & parallelism + +All seven are independent. Recommended order (cheapest/lowest-risk first): + +| # | Task | Risk | Files | +|---|------|------|-------| +| 1 | B6 — untrack audit scratch | trivial | git + `.gitignore` | +| 2 | W2 — pin starlette range | trivial | `pyproject.toml` | +| 3 | T4 — coverage floors for 3 security modules | trivial | `scripts/check_coverage_floors.py` | +| 4 | B1 — close `/api/v1/observations` auth hole | low | `dashboard_auth.py`, `dashboard.py`, test | +| 5 | B4 — preserve `actor` on `file_events` JSONL import | low | `db_meta.py`, test | +| 6 | D1 — reject silent signatureless un-govern | medium | `db_entity_associations.py`, test | +| 7 | B3 — Legis redirect-leak hardening (strip-not-reject) | medium-high | `legis_client.py`, test + fake | + +Each task ends green and committable. Run the full pre-merge gate (bottom of this doc) once after Task 7. + +--- + +## Task 1: B6 — Untrack internal audit scratch files + +**Files:** +- Modify: `.gitignore` +- Untrack: `READ_ONLY_CODEBASE_AUDIT_2026-06-04.md`, `READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md` (repo root) + +No test (git/config change). This is a hygiene fix: two internal audit scratch files are tracked and would ship in the release. + +**Step 1: Confirm they are tracked** + +Run: `git ls-files | grep -i 'READ_ONLY_CODEBASE_AUDIT'` + +Expected output: +``` +READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md +READ_ONLY_CODEBASE_AUDIT_2026-06-04.md +``` + +**Step 2: Untrack (keep the local files) and ignore** + +```bash +git rm --cached "READ_ONLY_CODEBASE_AUDIT_2026-06-04.md" "READ_ONLY_CODEBASE_AUDIT_2026-06-04-5AGENT.md" +``` + +Append to `.gitignore`: +``` +# Internal codebase-audit scratch — never ship in a release +READ_ONLY_CODEBASE_AUDIT_*.md +``` + +**Step 3: Verify they are no longer tracked but still present on disk** + +Run: `git ls-files | grep -i 'READ_ONLY_CODEBASE_AUDIT' | wc -l && ls READ_ONLY_CODEBASE_AUDIT_2026-06-04.md` + +Expected output: +``` +0 +READ_ONLY_CODEBASE_AUDIT_2026-06-04.md +``` + +**Step 4: Commit** + +```bash +git add .gitignore +git commit -m "chore: untrack internal codebase-audit scratch files + +These READ_ONLY_CODEBASE_AUDIT_* files are internal review scratch and +must not ship in the 3.0.0 release. Untracked and gitignored." +``` + +**Definition of Done:** +- [ ] Both files untracked (`git ls-files` returns nothing for them) +- [ ] `.gitignore` pattern added +- [ ] Local copies still on disk (not deleted) +- [ ] Committed + +--- + +## Task 2: W2 — Pin the Starlette version range + +**Files:** +- Modify: `pyproject.toml` (the `[project] dependencies` / `dependencies = [...]` list) + +**Context:** All three auth findings sit on Starlette middleware/Request semantics. The lockfile pins `starlette 1.0.1`, but the dependency constraint floats it (it comes transitively via `fastapi>=0.115`). Pin an explicit range so CI and production share middleware semantics across the 0.x→1.0 major boundary. + +**Step 1: Find the current constraint** + +Run: `grep -n -E 'fastapi|starlette' pyproject.toml` + +Expected: a `fastapi>=...` entry and **no** explicit `starlette` entry. + +**Step 2: Add an explicit Starlette constraint** + +In the `dependencies` array of `pyproject.toml`, add (alongside `fastapi`): +```toml + "starlette>=1.0,<2", +``` + +**Step 3: Re-resolve and verify the lock is unchanged at 1.0.x** + +Run: `uv lock && grep -A1 'name = "starlette"' uv.lock | head -3` + +Expected output (version line ≥ 1.0, < 2): +``` +name = "starlette" +version = "1.0.1" +``` + +**Step 4: Verify the suite still imports/collects** + +Run: `uv run pytest tests/api/test_loom_auth.py -q` + +Expected: all auth tests pass (this is the middleware drift signal). + +**Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: pin starlette>=1.0,<2 explicitly + +All bearer-auth middleware findings depend on Starlette 1.0 Request/ +middleware semantics. Pin the major so CI and production cannot diverge +across the 0.x->1.0 boundary (was floating via fastapi)." +``` + +**Definition of Done:** +- [ ] `starlette>=1.0,<2` in `pyproject.toml` dependencies +- [ ] `uv.lock` resolves to a 1.0.x starlette +- [ ] `tests/api/test_loom_auth.py` passes +- [ ] Committed + +--- + +## Task 3: T4 — Coverage floors for the new security modules + +**Files:** +- Modify: `scripts/check_coverage_floors.py` (the `FILE_FLOORS` dict, ~line 17) +- Verify: `tests/test_quality_gates.py` (already exercises this script — must stay green) + +**Context:** Per-module floors exist (`dashboard_auth.py:90`, `registry.py:80`) but the three modules carrying the Legis-governance and transport-identity security logic — `governance.py`, `actor_identity.py`, `legis_client.py` — have **no** floor. B2/B3 correctness lives in these. + +**Step 1: Measure current coverage for the three modules** + +Run: +```bash +uv run pytest --cov=filigree --cov-report=json -q >/dev/null +uv run python -c "import json;d=json.load(open('coverage.json'))['files'];\ +print({k.split('/')[-1]: round(v['summary']['percent_covered'],1) for k,v in d.items() if k.split('/')[-1] in {'governance.py','actor_identity.py','legis_client.py'}})" +``` + +Expected output (illustrative — record the real numbers): +``` +{'governance.py': 9X.X, 'actor_identity.py': 9X.X, 'legis_client.py': 8X.X} +``` + +**Step 2: Add floors set just below the measured values** + +In `scripts/check_coverage_floors.py`, add to `FILE_FLOORS` (pick floors a few points below the measured coverage so normal churn doesn't trip them, but high enough to catch a real regression): +```python + "src/filigree/governance.py": , + "src/filigree/actor_identity.py": , + "src/filigree/legis_client.py": , +``` +Keep the dict alphabetically ordered to match the existing style. + +**Step 3: Run the floor check against the fresh coverage.json** + +Run: `uv run python scripts/check_coverage_floors.py coverage.json` + +Expected output: +``` +(no output, exit 0) +``` + +**Step 4: Verify the quality-gate test still passes** + +Run: `uv run pytest tests/test_quality_gates.py -q` + +Expected: PASSED. + +**Step 5: Commit** + +```bash +git add scripts/check_coverage_floors.py +git commit -m "test: add coverage floors for governance/actor_identity/legis_client + +These three modules carry the Legis closure-gate, transport-identity, and +governance HTTP client logic but had no per-module floor. Floors set just +below current coverage to catch regressions in the security-critical paths." +``` + +**Definition of Done:** +- [ ] Three new `FILE_FLOORS` entries, floors below measured coverage +- [ ] `scripts/check_coverage_floors.py coverage.json` exits 0 +- [ ] `tests/test_quality_gates.py` green +- [ ] Committed + +--- + +## Task 4: B1 — Close the `/api/v1/observations` unauthenticated-write hole + +**Files:** +- Modify: `src/filigree/dashboard_auth.py:31` (`CLASSIC_FEDERATION_ALIASES`) +- Modify: `src/filigree/dashboard.py:104` (`_dashboard_auth_scope` introspection list) +- Test: `tests/api/test_loom_auth.py` (extend `TestIsLoomScopedPath` drift guard + add a `TestLoomAuthEnforcement` case) + +**Context:** `is_loom_scoped_path("/api/v1/observations")` → `rest="v1/observations"`, which is in neither alias set → returns `False` → the route at `dashboard_routes/analytics.py:544` accepts unauthenticated writes when a federation token is set. The three sibling observation-write routes are all gated; this classic alias is the lone hole. + +**Step 1: Write the failing tests** + +In `tests/api/test_loom_auth.py`, add to `class TestIsLoomScopedPath` the classic alias to the true-paths list and, critically, **extend the drift guard to the classic router** (the existing `test_every_living_surface_route_is_loom_scoped` only covers living-surface routers — this hole was on the classic router): + +```python + def test_classic_v1_observations_is_loom_scoped(self) -> None: + """Regression: the classic observation-write alias must be gated.""" + assert is_loom_scoped_path("/api/v1/observations") is True + assert is_loom_scoped_path("/api/p/acme/v1/observations") is True + + def test_every_classic_federation_alias_is_loom_scoped(self) -> None: + """Drift guard for the CLASSIC router. The living-surface guard above + does not iterate classic routes; the v1/observations hole proves a + classic-router guard is needed. Every classic federation write alias + must be gated under both the bare and server-mode mounts. + """ + for alias in ("v1/scan-results", "v1/observations"): + assert is_loom_scoped_path(f"/api/{alias}") is True, alias + assert is_loom_scoped_path(f"/api/p/acme/{alias}") is True, alias +``` + +And add an end-to-end enforcement case to `class TestLoomAuthEnforcement` (mirror `test_classic_v1_scan_results_enforced` and `test_living_alias_observations_correct_token`): + +```python + async def test_classic_v1_observations_enforced(self, app_factory: Callable[[str | None], FastAPI]) -> None: + """The classic observation-write alias must require the bearer token.""" + app = app_factory(TOKEN) + async with _client(app) as c: + unauth = await c.post("/api/v1/observations", json={"summary": "must be gated"}) + authed = await c.post( + "/api/v1/observations", + headers={"Authorization": f"Bearer {TOKEN}"}, + json={"summary": "classic alias accepted"}, + ) + assert unauth.status_code == 401 + assert authed.status_code == 201 +``` + +**Why these tests:** the predicate tests pin the gate; the enforcement test pins the real middleware behavior (401 without token, **201** — first create — with token); the classic-router drift guard prevents the *next* classic write alias from re-opening the same class of hole. + +**Step 2: Run to verify failure** + +Run: `uv run pytest "tests/api/test_loom_auth.py::TestIsLoomScopedPath::test_classic_v1_observations_is_loom_scoped" "tests/api/test_loom_auth.py::TestLoomAuthEnforcement::test_classic_v1_observations_enforced" -v` + +Expected: both FAIL — predicate returns `False`; the POST returns 201 (or 200) without a token instead of 401. + +**Step 3: Implement the fix** + +In `src/filigree/dashboard_auth.py:31`, add the classic alias: +```python +CLASSIC_FEDERATION_ALIASES: frozenset[str] = frozenset({"v1/scan-results", "v1/observations"}) +``` + +In `src/filigree/dashboard.py:104`, add the path to the federation `protected_paths` introspection list so operator tooling reports it correctly: +```python + "protected_paths": ["/api/loom/*", "/api/scan-results", "/api/observations", "/api/v1/scan-results", "/api/v1/observations"], +``` + +**Why minimal:** the predicate already strips the `/api/` and `p/{key}/` prefixes; adding the alias string is the entire behavioral fix. The introspection edit keeps the `/api/health` auth report truthful. + +**Step 4: Run to verify pass** + +Run: `uv run pytest tests/api/test_loom_auth.py -v` + +Expected: all PASS (including the new cases and the existing health-introspection tests). + +**Step 5: Commit** + +```bash +git add src/filigree/dashboard_auth.py src/filigree/dashboard.py tests/api/test_loom_auth.py +git commit -m "fix(auth): gate POST /api/v1/observations behind the federation token + +is_loom_scoped_path missed the classic v1/observations write alias, leaving +it unauthenticated when FILIGREE_FEDERATION_API_TOKEN was set (the 06-04 fix +covered v1/scan-results but not v1/observations). Add the alias, surface it +in the auth-scope introspection, and add a classic-router drift guard so the +next classic write alias cannot re-open the hole." +``` + +**Definition of Done:** +- [ ] `v1/observations` in `CLASSIC_FEDERATION_ALIASES` +- [ ] `protected_paths` introspection updated +- [ ] New predicate + enforcement + classic-router drift-guard tests pass +- [ ] Full `test_loom_auth.py` green +- [ ] Committed + +--- + +## Task 5: B4 — Preserve the `actor` column on `file_events` JSONL import + +**Files:** +- Modify: `src/filigree/db_meta.py:1415-1437` (merge import INSERT) and `:1439-1452` (non-merge import INSERT) +- Test: `tests/core/test_verified_actor.py` (extend the existing file_events round-trip test) + +**Context:** The `file_events` schema (`db_schema.py:230-240`) has both `actor` and `verified_actor`. Both import INSERTs list `verified_actor` but omit `actor`, so an export→import round-trip silently drops the claimed `actor`. (Verified: the `events`-table import at `db_meta.py:1354-1368` already includes `actor`; only `file_events` is affected — this is genuinely two one-line column additions.) + +**Step 1: Write the failing test** + +In `tests/core/test_verified_actor.py`, extend the existing file_events export/import round-trip test (around lines 143–177 it asserts `verified_actor` survives) to also assert `actor`. Mirror that test's existing scaffolding (DB fixture, the `export_jsonl`/`import_jsonl` helpers it already uses); add a `file_events` row with a non-empty `actor`, round-trip, and assert it is preserved: + +```python + def test_file_event_actor_survives_jsonl_round_trip(self, tmp_path) -> None: + # Arrange: register a file and record a file_event carrying a claimed actor. + # (reuse this test module's existing DB + export/import helpers) + src = make_db_with_file_event(actor="agent-claimed", verified_actor="agent-verified") + dump = export_jsonl(src) + + # Act: import into a fresh DB. + dst = import_jsonl_into_fresh_db(dump) + + # Assert: BOTH actor and verified_actor round-trip. + row = dst.conn.execute( + "SELECT actor, verified_actor FROM file_events ORDER BY created_at DESC LIMIT 1" + ).fetchone() + assert row["verified_actor"] == "agent-verified" + assert row["actor"] == "agent-claimed" # currently dropped → fails +``` + +> NOTE for the executor: use the *exact* fixture/helper names already present in `test_verified_actor.py` rather than the illustrative `make_db_with_file_event`/`export_jsonl` placeholders above — the existing round-trip test already constructs all of this for `verified_actor`; copy it and add the `actor` arm. + +**Why this test:** pins the data-loss regression — `verified_actor` survives today, `actor` does not. + +**Step 2: Run to verify failure** + +Run: `uv run pytest tests/core/test_verified_actor.py -k file_event_actor -v` + +Expected: FAIL — `assert row["actor"] == "agent-claimed"` gets `''` (the column default), because the import never wrote it. + +**Step 3: Implement — add `actor` to both INSERTs** + +Non-merge INSERT (`db_meta.py:1439-1452`): add `actor` to the column list and `record.get("actor", "")` to the values tuple (place it before `verified_actor` to match): +```python + cursor = self.conn.execute( + "INSERT INTO file_events " + "(file_id, event_type, field, old_value, new_value, actor, verified_actor, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + record.get("actor", ""), + record.get("verified_actor"), + _normalize_iso_to_utc(record.get("created_at")) or _now_iso(), + ), + ) +``` + +Merge INSERT (`db_meta.py:1415-1437`): add `actor` to the inserted column list and the `SELECT` placeholder values **only** — leave the `WHERE NOT EXISTS` dedup clause unchanged (actor is not part of dedup identity): +```python + cursor = self.conn.execute( + "INSERT INTO file_events (file_id, event_type, field, old_value, new_value, actor, verified_actor, created_at) " + "SELECT ?, ?, ?, ?, ?, ?, ?, ? " + "WHERE NOT EXISTS (" + " SELECT 1 FROM file_events " + " WHERE file_id = ? AND event_type = ? AND field = ? AND old_value = ? AND new_value = ? AND created_at = ?" + ")", + ( + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + record.get("actor", ""), + record.get("verified_actor"), + created, + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + created, + ), + ) +``` + +**Why minimal:** `actor` is purely additive to the column list; the dedup identity (file_id/event_type/field/old/new/created_at) is intentionally left unchanged so import idempotency is preserved. + +**Step 4: Run to verify pass** + +Run: `uv run pytest tests/core/test_verified_actor.py -v` + +Expected: all PASS. + +**Step 5: Commit** + +```bash +git add src/filigree/db_meta.py tests/core/test_verified_actor.py +git commit -m "fix(import): preserve file_events.actor on JSONL round-trip + +Both file_events import INSERTs listed verified_actor but omitted actor, +silently dropping the claimed actor on export->import. Add the column to +both the merge and non-merge paths; dedup identity is unchanged." +``` + +**Definition of Done:** +- [ ] `actor` added to both `file_events` import INSERTs +- [ ] `WHERE NOT EXISTS` dedup clause unchanged +- [ ] Round-trip test asserts both `actor` and `verified_actor` and passes +- [ ] Committed + +--- + +## Task 6: D1 — Stop silent signatureless un-govern by *preserving* the prior signature + +**Files:** +- Modify: `src/filigree/db_entity_associations.py:185-202` (`add_entity_association` UPSERT) +- Test: `tests/` entity-association test module (find it: `grep -rl "add_entity_association" tests/`) + +**Decision context (D1 = defect):** Today a re-attach with no `signature` clears a prior signature to NULL (UPSERT `signature = excluded.signature`), and the closure gate then short-circuits to PROCEED for an ungoverned issue — a loopback caller can dodge governance by re-attaching without a signature. + +**Why PRESERVE, not REJECT (verified against the callers):** the obvious "reject a signed→signatureless re-attach" guard **breaks confirmed-existing flows**. Two of the three callers re-attach without a signature as normal operation: +- `mcp_tools/entities.py:196` (`entity_association_add` MCP tool) passes no `signature`. +- `db_files.py:2443` (`promote_finding_and_attach_entity`) passes no `signature`, and its docstring (db_files.py:2425-2439) *relies on* idempotent re-attach refreshing the hash, with a convergence test (`test_promote_and_attach_retry_converges_after_attach_failure`). +Only `dashboard_routes/entities.py:161` threads `signature` through (from the request body). A reject guard would raise on the MCP/promote re-attach of any governed issue. + +The fix is therefore to **preserve** the stored signature when a re-attach supplies none — `signature = COALESCE(excluded.signature, entity_associations.signature)`. This closes the dodge (the signature is never cleared → the gate keeps firing → the issue stays governed) without breaking idempotent re-attach. Filigree never verifies the signature itself (Legis does, at gate time), so a stored signature carried across a `content_hash` change is **not a filigree correctness problem** — and un-governing now requires the explicit `remove_entity_association` path, which is the right place for it. + +> ⚠️ **OWNER FLAG (governance-policy, not code-blocking):** preserve carries a Legis sign-off forward onto changed content. That is safe *for filigree* (it never checks the signature), but it is a governance-semantics choice: "stay governed until explicitly removed" (preserve, chosen here) vs "a content change invalidates the sign-off" (which the original clear-on-drift encoded, but did so *silently* and exploitably). Confirm the preserve semantics with the owner. The reject alternative is documented above and is viable only if the MCP/promote callers are changed to thread the signature through — do not take it without owner sign-off. + +**Step 1: Write the failing test** + +In the entity-association test module (mirror its actual fixtures — `grep -rl "add_entity_association" tests/`): + +```python +def test_signatureless_reattach_preserves_prior_signature(db) -> None: + """The governed->ungoverned dodge: a re-attach with no signature must NOT + clear a prior signature. It is preserved, so the issue stays governed.""" + issue_id = db.create_issue(type="task", title="governed").id + db.add_entity_association(issue_id, "ent-1", "hash-v1", signature="sig-abc", signoff_seq=1) + + # Idempotent refresh (e.g. promote_finding_and_attach_entity) passes no signature. + db.add_entity_association(issue_id, "ent-1", "hash-v2") + + rows = db.list_entity_associations(issue_id) + row = next(r for r in rows if (r.get("clarion_entity_id") or r.get("entity_id")) == "ent-1") + assert row["signature"] == "sig-abc" # preserved → still governed + assert row["signoff_seq"] == 1 # preserved alongside + assert row["content_hash_at_attach"] == "hash-v2" # hash still refreshes + + +def test_reattach_with_new_signature_updates(db) -> None: + """Re-signing (explicit new signature) still refreshes the binding.""" + issue_id = db.create_issue(type="task", title="governed").id + db.add_entity_association(issue_id, "ent-1", "hash-v1", signature="sig-abc", signoff_seq=1) + db.add_entity_association(issue_id, "ent-1", "hash-v2", signature="sig-def", signoff_seq=2) + rows = db.list_entity_associations(issue_id) + row = next(r for r in rows if (r.get("clarion_entity_id") or r.get("entity_id")) == "ent-1") + assert row["signature"] == "sig-def" + assert row["signoff_seq"] == 2 +``` + +**Why these tests:** the first locks the security property (signatureless re-attach cannot clear/un-govern) while proving the idempotent refresh path still works; the second proves explicit re-signing still updates. + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k "preserves_prior_signature or new_signature" -v` + +Expected: `test_signatureless_reattach_preserves_prior_signature` FAILS — `row["signature"]` is `None` (silently cleared) today. + +**Step 3: Implement — COALESCE-preserve signature and signoff_seq on re-attach** + +In `add_entity_association`'s UPSERT (`db_entity_associations.py:191-199`), change the two governance columns so a missing (`None`) incoming value keeps the stored one: + +```python + ON CONFLICT(issue_id, clarion_entity_id) DO UPDATE SET + content_hash_at_attach = excluded.content_hash_at_attach, + attached_at = excluded.attached_at, + entity_kind = CASE + WHEN excluded.entity_kind <> '' THEN excluded.entity_kind + ELSE entity_associations.entity_kind + END, + signature = COALESCE(excluded.signature, entity_associations.signature), + signoff_seq = COALESCE(excluded.signoff_seq, entity_associations.signoff_seq) +``` + +Update the misleading comment at `db_entity_associations.py:181-184`: +```python + # signature/signoff_seq (v25, B1) are refreshed on re-attach ONLY when a + # new value is supplied; a signatureless re-attach preserves the prior + # signature (D1) so it cannot silently un-govern. Explicit un-govern goes + # through remove_entity_association(). +``` + +**Why minimal:** the entire behavioral fix is the two `COALESCE`s; no extra read, no new code path. Passing a real `signature` still updates it (excluded value is non-null); passing `None` preserves the stored one. + +**Step 4: Run to verify pass — including the callers that re-attach tokenless** + +Run: +```bash +uv run pytest -v +uv run pytest tests/ -k "promote_and_attach or entity_association" -q +uv run pytest tests/ -k "governance or closure_gate" -q +``` + +Expected: all PASS — crucially the `promote_and_attach` retry/convergence test (which re-attaches without a signature) is unaffected. + +**Step 5: Commit** + +```bash +git add src/filigree/db_entity_associations.py tests/ +git commit -m "fix(governance): preserve signature on signatureless re-attach + +A re-attach with no signature cleared a prior signature to NULL, letting a +loopback caller dodge the Legis closure gate (review M-01). COALESCE-preserve +signature/signoff_seq so a tokenless re-attach can no longer un-govern; the +hash still refreshes. Chosen over a reject guard because the MCP add tool and +promote_finding_and_attach_entity re-attach without a signature as normal +idempotent operation. Explicit un-govern remains remove_entity_association(). + +OWNER NOTE: preserve carries a sign-off across a content_hash change — safe +for filigree (never verifies the signature; Legis does at gate time) but a +governance-semantics choice to confirm (see plan Task 6 flag)." +``` + +**Definition of Done:** +- [ ] UPSERT uses `COALESCE` for `signature` and `signoff_seq` +- [ ] Misleading "documented flip" comment corrected +- [ ] Preserve + re-sign tests pass +- [ ] Existing **entity-association re-attach AND `promote_and_attach` convergence** tests still pass (this is where the reject alternative would have broken) +- [ ] Owner governance-semantics flag captured in the commit message +- [ ] Committed + +--- + +## Task 7: B3 — Legis redirect-leak hardening (strip-token-on-redirect, classify benign vs malicious) + +**Files:** +- Modify: `src/filigree/legis_client.py:94-146` (request construction + error classification) +- Test/fake: `tests/_fakes/legis_http.py` (extend to a two-server 302→sink harness) +- Test: the Legis client test module (`grep -rl "legis_stub\|check_closure_gate" tests/`) + +**Context & the landmine:** `check_closure_gate` (legis_client.py:94-101) sends `LEGIS_API_TOKEN` as a bearer and, via stdlib `urllib.request.urlopen`, **follows redirects with the token attached** — a compromised/malicious Legis server can 302-exfiltrate the bearer to another host. The naive fix (drop `HTTPRedirectHandler`) is wrong: with no handler, a *legitimate* 3xx (load balancer, http→https, path normalization) is raised as `urllib.error.HTTPError`, falls through `_classify_http_error`'s catch-all (legis_client.py:144-146) to `UNREACHABLE`, and combined with governance's fail-closed policy **blocks every governed close federation-wide**. `registry.py`'s httpx `follow_redirects=False` is non-raising and is **not** behavior-equivalent — do not copy it literally. + +**Chosen semantics (matches `registry.py`'s intent): STRIP, don't reject.** On a redirect, drop the `Authorization` header and follow the benign redirect; never carry the bearer cross-origin. This keeps legitimate redirecting topologies working while closing the exfiltration vector. + +**Step 1: Extend the test fake to a two-server redirect harness** + +In `tests/_fakes/legis_http.py`, add a redirecting-stub context manager. The primary server returns `302 + Location` pointing at a sink server; the sink records any inbound `Authorization` header so the test can assert it was stripped: + +```python +@dataclass +class RedirectSinkState: + """Records requests (and any Authorization header) that reach the sink.""" + + requests: list[str] = field(default_factory=list) + auth_headers: list[str | None] = field(default_factory=list) + body: dict[str, Any] = field(default_factory=lambda: {"allowed": True, "reason": "ok"}) + + +def _build_sink_handler(state: RedirectSinkState) -> type[BaseHTTPRequestHandler]: + class _Sink(BaseHTTPRequestHandler): + def log_message(self, *_args: Any) -> None: + pass + + def do_GET(self) -> None: + state.requests.append(self.path) + state.auth_headers.append(self.headers.get("Authorization")) + payload = json.dumps(state.body).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + return _Sink + + +def _build_redirect_handler(location: str) -> type[BaseHTTPRequestHandler]: + class _Redirect(BaseHTTPRequestHandler): + def log_message(self, *_args: Any) -> None: + pass + + def do_GET(self) -> None: + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + + return _Redirect + + +@contextmanager +def legis_redirect_to_sink() -> Iterator[tuple[str, RedirectSinkState]]: + """Yield (primary_base_url, sink_state). The primary 302-redirects every + closure-gate GET to a sink server that records inbound Authorization.""" + sink_state = RedirectSinkState() + sink = ThreadingHTTPServer(("127.0.0.1", 0), _build_sink_handler(sink_state)) + sink_host, sink_port = sink.server_address[:2] + sink_thread = threading.Thread(target=sink.serve_forever, daemon=True) + sink_thread.start() + # Redirect any path to the sink root; the sink answers 200 for any path. + primary = ThreadingHTTPServer( + ("127.0.0.1", 0), _build_redirect_handler(f"http://{sink_host}:{sink_port}/redirected") + ) + primary_host, primary_port = primary.server_address[:2] + primary_thread = threading.Thread(target=primary.serve_forever, daemon=True) + primary_thread.start() + try: + yield f"http://{primary_host}:{primary_port}", sink_state + finally: + for srv in (primary, sink): + srv.shutdown() + srv.server_close() + primary_thread.join(timeout=2.0) + sink_thread.join(timeout=2.0) +``` + +**Step 2: Write the failing tests** + +In the Legis client test module: + +```python +def test_redirect_does_not_leak_bearer_token(monkeypatch) -> None: + """A 302 from Legis must NOT carry the Authorization header to the redirect + target — the token is stripped before following.""" + from filigree import legis_client + + with legis_redirect_to_sink() as (base_url, sink_state): + monkeypatch.setenv(legis_client.LEGIS_URL_ENV, base_url) + monkeypatch.setenv(legis_client.LEGIS_TOKEN_ENV, "super-secret-bearer") + result = legis_client.check_closure_gate("filigree-abc123") + + # The redirect was followed (sink saw the request) ... + assert sink_state.requests, "redirect was not followed" + # ... but the bearer was stripped, never reaching the redirect target. + assert all(h is None for h in sink_state.auth_headers), sink_state.auth_headers + # And a legitimate redirect does NOT degrade to a fail-closed UNREACHABLE. + assert result.status is legis_client.LegisGateStatus.ALLOWED + + +def test_non_redirecting_gate_still_sends_token(monkeypatch) -> None: + """Sanity: without a redirect, the bearer is still sent (no regression).""" + from filigree import legis_client + + with legis_stub() as (base_url, state): + state.body = {"allowed": True, "reason": "ok"} + monkeypatch.setenv(legis_client.LEGIS_URL_ENV, base_url) + monkeypatch.setenv(legis_client.LEGIS_TOKEN_ENV, "tok") + result = legis_client.check_closure_gate("filigree-abc123") + assert result.status is legis_client.LegisGateStatus.ALLOWED +``` + +**Why these tests:** the first pins both halves of the fix — the redirect is *followed* (no fail-closed regression) AND the token is *stripped* (no leak). The second guards against over-correcting into never sending the token. + +**Step 3: Run to verify failure** + +Run: `uv run pytest -k "redirect or still_sends" -v` + +Expected: `test_redirect_does_not_leak_bearer_token` FAILS — today the sink's `auth_headers` contains `"Bearer super-secret-bearer"` (urllib's default redirect handler re-sends the header). + +**Step 4: Implement — a token-stripping redirect handler + scheme/loopback guard** + +In `legis_client.py`, add a custom redirect handler that drops `Authorization` on redirect, and build an opener that uses it. Add scheme validation for the configured base URL. Replace the bare `urlopen` with the opener: + +```python +class _StripAuthRedirectHandler(urllib.request.HTTPRedirectHandler): + """Follow redirects but never carry the bearer token across them. + + urllib's default handler re-sends request headers (including + Authorization) to the redirect target. A malicious or compromised Legis + server could 302 the token to an attacker host. We strip Authorization on + every redirected request, so a benign redirect (LB / http->https) still + works while the bearer never leaves the originally-configured origin. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 - stdlib signature + new = super().redirect_request(req, fp, code, msg, headers, newurl) + if new is not None: + new.headers.pop("Authorization", None) + new.unredirected_hdrs.pop("Authorization", None) + return new + + +def _validate_legis_scheme(url: str) -> None: + """Reject a non-http(s) LEGIS_URL before attaching a bearer to it.""" + from urllib.parse import urlparse + + scheme = urlparse(url).scheme + if scheme not in {"http", "https"}: + msg = f"LEGIS_URL must be an http(s) URL, got scheme {scheme!r}" + raise ValueError(msg) +``` + +Then in `check_closure_gate`, after computing `url` and before the request, validate the scheme, and use an opener built with the stripping handler: + +```python + _validate_legis_scheme(url) + req = urllib.request.Request(url, headers=headers, method="GET") # noqa: S310 + opener = urllib.request.build_opener(_StripAuthRedirectHandler()) + try: + with opener.open(req, timeout=timeout) as resp: # noqa: S310 + body = _read_json(resp.read()) + ... +``` + +(Keep the existing `except urllib.error.HTTPError` / `URLError` handling unchanged — with the stripping handler, a benign 3xx is followed rather than raised, so it no longer hits the `UNREACHABLE` catch-all.) + +**Why this shape:** strips the token on redirect (closes the leak) while still following benign redirects (no federation-wide fail-closed); scheme validation prevents attaching a bearer to a `file://`/other-scheme `LEGIS_URL`. This mirrors `registry.py`'s strip-then-follow intent without copying its httpx-only kwargs. + +> **Residual trust note (non-blocking):** strip-and-follow trusts the verdict returned by the *redirect target*. This is defensible — Legis is the governance trust authority, and a malicious Legis could simply return `allowed:true` directly without a redirect; TLS protects the honest path. But an *honest-Legis open-redirect* would let an attacker-controlled host answer the gate. If you want to also close that, restrict the follow to the **same origin** as `LEGIS_URL` (compare scheme+host+port in `redirect_request`, return `None` to refuse a cross-origin redirect). Recommended as a cheap hardening; call the owner's preference if unsure. + +**Step 5: Run to verify pass** + +Run: `uv run pytest -v` + +Expected: all PASS — redirect followed, token stripped, non-redirect path still sends the token, existing classification tests unaffected. + +**Step 6: Commit** + +```bash +git add src/filigree/legis_client.py tests/_fakes/legis_http.py tests/ +git commit -m "fix(legis): strip bearer token on redirect, validate scheme + +check_closure_gate followed redirects with the LEGIS_API_TOKEN attached, so +a malicious Legis server could 302-exfiltrate the bearer. Add a redirect +handler that drops Authorization across redirects (benign LB/https redirects +still work, the token never leaves the configured origin) and validate the +LEGIS_URL scheme before attaching the token. Dropping HTTPRedirectHandler +entirely was rejected: a benign 3xx would raise and degrade every governed +close to fail-closed UNREACHABLE federation-wide." +``` + +**Definition of Done:** +- [ ] `_StripAuthRedirectHandler` follows redirects but removes `Authorization` +- [ ] `LEGIS_URL` scheme validated before the bearer is attached +- [ ] Two-server harness in `legis_http.py`; leak + no-regression tests pass +- [ ] Existing closure-gate classification tests unaffected +- [ ] Committed + +--- + +## Pre-merge verification (run once, after Task 7) + +Per the repo CI gate (CLAUDE.md pre-push checklist): + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +make coverage-floors # exercises the new governance/actor_identity/legis_client floors +``` + +No JS changed in this slice, so the biome gate is not required. + +Expected: all green. If `make coverage-floors` trips on one of the three new floors, the floor was set too high in Task 3 — lower it to just below the measured value (it documents current coverage, it is not a coverage *goal*). + +--- + +## Handoff notes + +- **Decisions still open (not in this plan):** D2 (cascade-close policy → B2/B5) and D3 (§4 tiering → T2.1 + deferrals). v2 of the umbrella plan (`/tmp/filigree-3.0.0-remediation-plan.md`) holds those; plan them after the discussion. +- **Owner flag in Task 6:** the D1 fix preserves the signature on tokenless re-attach (so Clarion drift-refresh keeps working). Confirm the governance semantics — "stay governed until explicit removal" — are what's intended. +- **External wire (carried, not in this slice):** before any D3 work, confirm Clarion/Wardline do not read the `get_stats` deprecated keys or the `clarion_entity_id` JSONL key. +- **F-003 correction:** the review/umbrella-plan-v1 inverted this — bundled scanners are *protected* by the legacy-token fallback (`scan_utils.py:348-352`); only a custom scanner with a non-default `--api-token-env` and legacy-only token is exposed. No code task here; ensure the release note reflects the corrected scope. diff --git a/docs/plans/2026-06-05-rebrand-execution-prompt.md b/docs/plans/2026-06-05-rebrand-execution-prompt.md new file mode 100644 index 00000000..19843ddc --- /dev/null +++ b/docs/plans/2026-06-05-rebrand-execution-prompt.md @@ -0,0 +1,119 @@ +# Execution prompt — Clarion→Loomweave / Loom→Weft rebrand + +Hand this to a fresh agent (or paste as the opening prompt) to execute the +rebrand. It is self-contained except for the two reference docs it points at. + +--- + +## PROMPT (copy from here) + +You are executing a **federation rebrand** of the Filigree codebase, on branch +`release/3.0.0`. Two independent rename axes: + +- **Clarion → Loomweave** (the registry/SEI sibling product) +- **Loom → Weft** (the federation; inside Filigree also the named API + generation `/api/loom/*` + the `generations/loom/` module + the `loom://` + URI scheme) + +**Decision (fixed — do not relitigate):** this is a **hard wire-break** riding +the 3.0.0 train. **No compatibility aliases** for the new contracts. It +co-ships with the MCP-namespacing breaking change so consumers cut over once. + +### Read first (do not skip) +1. `docs/plans/2026-06-05-clarion-loomweave-loom-weft-rebrand-inventory.md` — + the full rub-point inventory: tiers, ownership tags, exact file:line fix + targets, the **confirmed name table**, and coordination items. This is your + spec. +2. Epic `filigree-1d08ffb493` and its subtasks (G0, T2A, T2B, T0, T1, T0b, T3, + Parked) — each subtask is one unit of work with acceptance criteria. Run + `filigree show ` per subtask. Claim with + `filigree start-work --assignee `; close with `--reason`. + +### Names — CONFIRMED vs PENDING (read the inventory's names table for status) +Use **only the CONFIRMED names** in code. The rest are proposals G0 must lock — +**do not flip a PENDING wire/data value on a guess** (that is what G0 is for; a +wrong-but-confident contract name is worse than a blank). + +- **CONFIRMED (safe to apply):** `Clarion`→`Loomweave` · `Loom`→`Weft` · + `/api/loom/*`→`/api/weft/*` (gen token `"loom"`→`"weft"`) · + `clarion_entity_id`→`loomweave_entity_id` · `CLA-`→`LMWV-`. +- **PENDING G0 (do NOT apply until G0 closes):** `CLARION_LOOM_TOKEN`→? · + audience `"loom"`→? · error codes `CLARION_*`→? · `clarion:eid:`→? · + `registry_backend "clarion"`/`[clarion]`→? · `loom://`→?. The inventory lists + this author's *proposed* targets, but they are unconfirmed. +- **BLOCKED:** Legis's new name is unknown — subtask "Parked" stays blocked + until the hub publishes it; do not guess. + +Note the CONFIRMED set is exactly enough to fully execute **T2A and T2B** (the +product-name and module renames) without touching any pending contract value. + +### Hard guardrails +- 🚫 **Never blind-`sed` "loom".** It is a substring of `bloom`/`gloom` and + appears in prose. Axis B is done **by identifier** (rename symbols, move the + module, fix imports) — use LSP/grep-then-edit, not text replace. `clarion` + is safe to bulk-replace; `loom` is not. +- 🚫 **Never create or switch branches.** Work on `release/3.0.0` as-is. (User + rule: no branch changes without explicit approval; no worktrees.) +- 🔴 **G0 gates the wire + data tiers.** T0, T1, T0b, and Parked are blocked by + G0 (the hub locking names + the Legis re-sign protocol). `filigree ready` + will show them as not-startable until G0 closes. **Do not start a blocked + tier** — `filigree blocked` to confirm. +- 🤝 **Lockstep items are JOINT/SIBLING** (see ownership tags). The emitted + entity key, SEI prefix, `CLA-` rule prefix, audience claim, and env var must + match what Loomweave/the hub emit. If the sibling side isn't confirmed for an + item, leave it and comment on the subtask — don't unilaterally flip a + contract value. + +### Execution order (honor the dependency graph) +1. **Startable now (Filigree-owned, no gate):** + - **T2A** `filigree-0d403dc684` — Clarion→Loomweave internal code (registry/SEI + symbols, constants, attrs). Leave wire/data *values* (`CLARION_LOOM_TOKEN`, + `clarion:eid:`, error-code strings) untouched — those are T1/T0. + - **T2B** `filigree-cda5448d48` — move `generations/loom/`→`generations/weft/`, + rename `*Loom` DTOs, `*_to_loom` adapters, `create_loom_router`. **By + identifier.** WIRE-visible bits (route prefix, gen token) flip in T1. + These two are independent — can run in parallel by different agents. +2. **After G0 closes:** + - **T0** `filigree-e0896844cd` — data migrations (column→`loomweave_entity_id`, + `registry_backend` literal, `[clarion]` section, SEI prefix, finding + `rule_id` `CLA-`→`LMWV-`). Each needs a schema migration + back-compat read + during rewrite. **Gate the SEI-prefix/key rewrite on the Legis re-sign + protocol (T0b).** + - **T1** `filigree-648e6460d4` — wire flip (routes, gen token, audience, env + var, error codes, remediation string, capability probe, URI scheme). Ship + in the SAME 3.0.0 cut as MCP namespacing. +3. **T0b** `filigree-2cf022fff2` — coordinate the Legis HMAC re-sign over renamed + `entity_id`s (sibling-driven; Filigree provides the re-import path). Stored + signatures are stale-by-design until Legis re-cuts — expected, not corruption. +4. **T3** `filigree-44a56a8912` — docs, ADRs, CHANGELOG (`[3.0.0]` only; keep + shipped history), CI, test fixtures. Last, for coherence. +5. **Parked** `filigree-58ccd105b7` — Legis surface rename. **Blocked on Legis's + new name from the hub.** + +### Verify before every close (no exceptions) +Run the full project CI gate and paste real output into the close `--reason`: +``` +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +``` +If you touched JS under `src/filigree/static/js/`, also run the biome gate: +`npx biome lint ` and `npx biome format `. + +Migrations (T0): write a forward migration with a bumped schema version, prove +round-trip on a copy of a real `.db`, and confirm reads of pre-migration rows +still work during the rewrite window. Do not ship a rename that breaks reads. + +Wire (T1): grep the repo for the OLD token after the change +(`git grep -n 'api/loom\|CLARION_LOOM_TOKEN\|clarion:eid:\|"loom"'`) and confirm +zero live-code hits remain (docs/CHANGELOG history excepted). + +### When blocked or uncertain on a contract value +Comment on the subtask with what you need from the sibling/hub, add the +dependency, and move to another startable item. Do not invent a name or flip a +shared value on a guess. Surface genuinely ambiguous design calls (e.g. whether +the entity key should be opaque `entity_id` vs branded `loomweave_entity_id`) +to the user rather than deciding silently. + +## (end prompt) diff --git a/docs/plans/2026-06-06-loomweave-weft-rebrand-execution-plan.md b/docs/plans/2026-06-06-loomweave-weft-rebrand-execution-plan.md new file mode 100644 index 00000000..8dbe7bc7 --- /dev/null +++ b/docs/plans/2026-06-06-loomweave-weft-rebrand-execution-plan.md @@ -0,0 +1,706 @@ +# Loomweave / Weft Rebrand — Execution Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cut Filigree's entire federation contract surface from the Clarion/Loom names to the locked Loomweave/Weft names — code identifiers, the stored data (DB column, SEI prefix, finding rule-id prefix), the wire surface (`/api/loom`→`/api/weft`, audience, token env var), and the docs — as the **last change on `release/3.0.0`** before the major is cut. + +**Architecture:** Hard wire-break, **no compatibility aliases** (owner decision 2026-06-05). Two independent rename axes — **A: Clarion→Loomweave** (sibling product / registry / SEI), **B: Loom→Weft** (federation + the named API generation). Tier-ordered by coordination cost: isolated code renames first (T2A, T2B), then the `v25→v26` data migration (T0), then the wire flip (T1), then docs (T3). A Legis HMAC re-sign pass is coordinated out-of-band (see Task 9). + +**Tech Stack:** Python 3 / SQLite (PRAGMA `user_version` migrations) / FastAPI / pytest / ruff / mypy. Dashboard JS under `static/js/` uses the separate biome gate. + +--- + +## Locked contract (G0 — published by the hub) + +| Surface | Old | New (LOCKED) | +|---|---|---| +| Sibling product | `Clarion` | `Loomweave` | +| Federation | `Loom` | `Weft` | +| HTTP generation | `/api/loom/*`, gen `"loom"` | `/api/weft/*`, gen `"weft"` | +| Entity-assoc column + emit key | `clarion_entity_id` | `loomweave_entity_id` | +| SEI value prefix | `clarion:eid:` | `loomweave:eid:` | +| Finding rule-id prefix | `CLA-` | `LMWV-` | +| Federation token env var | `CLARION_LOOM_TOKEN` | **`WEFT_TOKEN`** ⚠️ (hub locked the short form — our inventory had proposed `LOOMWEAVE_WEFT_TOKEN`; `WEFT_TOKEN` wins) | +| Token audience claim | `"loom"` | `"weft"` | +| `registry_backend` literal / `[clarion]` section | `"clarion"` / `[clarion]` | `"loomweave"` / `[loomweave]` | + +**Decisions baked into this plan (owner, 2026-06-06):** +- **SEI prefix ships now**, with a Legis re-sign pass coordinated in lockstep (Task 9). Stored signatures are stale-pending-reissue — an accepted, documented transient; Filigree never verifies them, so reads do not break. +- **Lands directly on `release/3.0.0`** (current branch). No branch switch. +- **This is the last change on the branch.** Cover all tiers to completion; leave the suite green and the CHANGELOG closed. + +## QUARANTINED — explicitly NOT in this plan (hub has not locked them) + +Do **not** touch these; they are not yet blessed by G0. Each is a tracked residual: +1. Registry **error codes** `CLARION_REGISTRY_VERSION_MISMATCH` / `CLARION_OUT_OF_SYNC` — table is silent on whether the `ErrorCode` enum values rename. Leave as-is. +2. **`weft://` URI scheme** (`loom://` stays until locked). +3. **Capabilities / `api_version` probe** endpoint + header semantics. +4. **Legis surface rename** — Legis's new name is unpublished (parked subtask `filigree-58ccd105b7`). + +> If the hub publishes these mid-execution, add tasks; do not improvise names. + +## Pre-flight (run once, before Task 1) + +- [ ] Confirm on branch `release/3.0.0`: `git -C . branch --show-current` → `release/3.0.0`. +- [ ] Baseline green: `uv run pytest --tb=short` → all pass. (If red at baseline, stop and report — do not start a rename on a red suite.) +- [ ] Skim the inventory for anchor context: `docs/plans/2026-06-05-clarion-loomweave-loom-weft-rebrand-inventory.md`. + +> **Execution caveat (applies to every task):** `clarion` is a safe, distinctive token. **`loom` is NOT** — it is a substring of `bloom`/`gloom` and doubles as the API-generation name. **Axis B (`loom`→`weft`) must be renamed by identifier, never by blind `sed`.** + +--- + +## Task 1: T2A — rename Clarion→Loomweave internal code (axis A) + +**Scope:** Python *identifiers* only — types, functions, constants, attrs, locals. **Do NOT change** in this task: the DB column string `clarion_entity_id`, the `SEI_PREFIX = "clarion:eid:"` value, the `registry_backend` literal `"clarion"`, or any historical migration body — those are DATA, migrated in Tasks 3–4. + +**Files (heaviest first):** `src/filigree/registry.py` (177), `core.py` (150), `sei_backfill.py` (55), `db_entity_associations.py` (34), `cli_commands/files.py` (15), `cli_commands/sei.py` (12), `mcp_tools/entities.py` (12), `dashboard_routes/files.py` / `entities.py`, `types/core.py`, `db_schema.py`, `install_support/doctor.py`. Tests under `tests/` that exercise them (rename in lockstep). + +**Rename map (identifiers):** +- Types/classes: `ClarionRegistry`→`LoomweaveRegistry`, `ClarionConfig`→`LoomweaveConfig`, `ClarionEntityId`→`LoomweaveEntityId`, `ClarionResolvedFile`→`LoomweaveResolvedFile`, `ClarionOutOfSyncError`→`LoomweaveOutOfSyncError`, `ClarionRotationBanner`→`LoomweaveRotationBanner`. +- Factories/helpers: `make_clarion_entity_id`→`make_loomweave_entity_id`, `_build_clarion_registry`→`_build_loomweave_registry`, `normalize_clarion_base_url`→`normalize_loomweave_base_url`, `_ClarionLocalFallbackRegistry`→`_LoomweaveLocalFallbackRegistry`, `probe_clarion_capabilities`→`probe_loomweave_capabilities`, `reprobe_clarion_capabilities`→`reprobe_loomweave_capabilities`, `validate_clarion_capabilities`→`validate_loomweave_capabilities`, `_run_initial_clarion_capability_probe`→`_run_initial_loomweave_capability_probe`, `_resolve_clarion_auth_token`→`_resolve_loomweave_auth_token`, `_validate_clarion_token_origin`→`_validate_loomweave_token_origin`, `require_clarion_base_url`→`require_loomweave_base_url`, `skip_clarion_capability_probe`→`skip_loomweave_capability_probe`, `_clarion_headers`→`_loomweave_headers`, `_clarion_follow_redirects`→`_loomweave_follow_redirects`, `clarion_files_batch_url`→`loomweave_files_batch_url`, `clarion_file_read_url`→`loomweave_file_read_url`. +- Constants: `DEFAULT_CLARION_TOKEN_ENV`→`DEFAULT_LOOMWEAVE_TOKEN_ENV`, `CLARION_BATCH_MAX_QUERIES`→`LOOMWEAVE_BATCH_MAX_QUERIES`, `EXPECTED_CLARION_API_VERSION`→`EXPECTED_LOOMWEAVE_API_VERSION`, `CLARION_RESOLVE_FILE_MAX_ATTEMPTS`→`LOOMWEAVE_RESOLVE_FILE_MAX_ATTEMPTS`, `CLARION_RESOLVE_FILE_RETRY_BACKOFF_SECONDS`→`LOOMWEAVE_RESOLVE_FILE_RETRY_BACKOFF_SECONDS`. +- Attrs/locals: `clarion_config`→`loomweave_config`, `clarion_api_version`→`loomweave_api_version`, `_clarion_base_url`→`_loomweave_base_url`, `_clarion_headers`→`_loomweave_headers`, `_clarion_timeout_seconds`→`_loomweave_timeout_seconds`, `_clarion_follow_redirects`→`_loomweave_follow_redirects`, `clarion_conn`→`loomweave_conn`, `clarion_db_path`→`loomweave_db_path`, `clarion_instance_id`→`loomweave_instance_id`, `clarion_instance_rotated`→`loomweave_instance_rotated`, `unknown_clarion_keys`→`unknown_loomweave_keys`, `clarion_identity_resolve_batch_url`→`loomweave_identity_resolve_batch_url`, `clarion_files_batch_url`→`loomweave_files_batch_url`, `clarion_capabilities_url`→`loomweave_capabilities_url`, `clarion_file_read_url`→`loomweave_file_read_url`. + +- [ ] **Step 1: Rename by identifier (IDE/LSP rename-symbol, one symbol at a time).** + +Use symbol-rename (not text replace) for each entry in the map above. The DB-column TypedDict field `clarion_entity_id` (in `EntityAssociationRow`, `db_entity_associations.py`) **stays** this task — it tracks the physical column, renamed in Task 3. The `ClarionEntityId` *type alias* renames to `LoomweaveEntityId` now; update the `EntityAssociationRow` field *type* (`entity_id: LoomweaveEntityId`, `clarion_entity_id: LoomweaveEntityId`) but not the field *name* `clarion_entity_id`. + +- [ ] **Step 2: Verify no brand *identifier* remains (data values excepted).** + +Run: +```bash +grep -rnE "Clarion|clarion_(config|conn|api_version|db_path|instance|headers|timeout|capabilities|file|files|identity|base_url)|_clarion_|make_clarion|probe_clarion|_build_clarion|DEFAULT_CLARION|EXPECTED_CLARION|CLARION_(BATCH|RESOLVE)" src/filigree/ --include=*.py +``` +Expected: the **only** surviving `clarion` hits are the deliberate data values — the column name `clarion_entity_id` (TypedDict field + historical migrations), `SEI_PREFIX = "clarion:eid:"`, `registry_backend == "clarion"` literals, the `[clarion]` config keys, and the `CLARION_*` error-code enum (quarantined). Everything CamelCase/identifier should be gone. Eyeball the list against that allowlist. + +- [ ] **Step 3: Type-check (drives completeness — flags every missed reference).** + +Run: `uv run mypy src/filigree/` +Expected: clean. A `name-defined`/`attr-defined` error names any call site you missed — fix and re-run. + +- [ ] **Step 4: Run the suite.** + +Run: `uv run pytest tests/ -k "registry or clarion or loomweave or sei or entit or capabilit" -q` +Expected: PASS (rename test files in lockstep so imports resolve). + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/ tests/ +git commit -m "refactor(rebrand): rename Clarion->Loomweave internal code (axis A, T2A) + +Mechanical identifier rename of the registry/SEI internals — types, +factories, constants, attrs. Data values (clarion_entity_id column, +clarion:eid: SEI prefix, registry_backend literal) are untouched here; +they migrate in the v26 data pass. No external contract changes in this +commit." +``` + +**Definition of Done:** +- [ ] All map identifiers renamed; grep shows only the data-value allowlist surviving +- [ ] mypy clean; targeted suite green +- [ ] DB column field name `clarion_entity_id` deliberately unchanged (Task 3 owns it) +- [ ] Committed + +--- + +## Task 2: T2B — rename `generations/loom/` → `generations/weft/` (axis B) + +**Scope:** the named API-generation module. **By identifier only** — `loom` is substring-hazardous. + +**Files:** package dir `src/filigree/generations/loom/` → `generations/weft/` (`__init__.py`, `types.py` ~79 hits, `adapters.py` ~76 hits); call sites `dashboard_routes/issues.py` (103), `files.py` (44), `analytics.py` (23), `releases.py`; imports throughout. Tests in lockstep. + +**Rename map:** +- Dir: `src/filigree/generations/loom/` → `src/filigree/generations/weft/` (`git mv`). +- Router factory: `create_loom_router` → `create_weft_router` (8 sites). +- DTO types: every `*Loom` → `*Weft` — `IssueLoom`, `SlimIssueLoom`, `IssueLoomWithFiles`, `IssueLoomWithUnblocked`, `BlockedIssueLoom`, `FileRecordLoom`, `FileAssocLoom`, `ScanFindingLoom`, `ScanIngestResponseLoom`, `ScannerLoom`, `PackLoom`, `ObservationLoom`, `CommentRecordLoom`, `ChangeRecordLoom`, `IssueEventLoom`, `TypeSummaryLoom`, `ScannerConfigLoom`, … → `*Weft`. +- Adapter fns: every `*_to_loom` → `*_to_weft` — `issue_to_loom`, `slim_issue_to_loom`, `scan_finding_to_loom`, `observation_to_loom`, `file_record_to_loom`, `change_record_to_loom`, `comment_record_to_loom`, `scan_ingest_result_to_loom`, `type_template_to_loom`, `scanner_config_to_loom`, `blocked_issue_to_loom`, `pack_to_loom`, `file_assoc_to_loom`, `issue_event_to_loom`, … → `*_to_weft`. + +> The HTTP **route** prefix `/api/loom` and the generation **token** `"loom"` are the *wire* surface — they flip in **Task 5**, not here. This task is the Python module/type/fn names only. + +- [ ] **Step 1: Move the package.** + +```bash +git mv src/filigree/generations/loom src/filigree/generations/weft +``` + +- [ ] **Step 2: Rename symbols by identifier.** + +Symbol-rename each `*Loom` type, each `*_to_loom` fn, and `create_loom_router`→`create_weft_router`. Update all imports (`from filigree.generations.loom...` → `...weft...`). Do **not** text-replace bare `loom` — only the listed identifiers. + +- [ ] **Step 3: Verify no `*Loom`/`_to_loom`/`generations.loom` identifier remains.** + +Run: +```bash +grep -rnE "Loom\b|_to_loom|generations[./]loom|create_loom_router" src/filigree/ --include=*.py +``` +Expected: zero hits. (Bare `/api/loom` strings and gen `"loom"` token remain until Task 5 — those are not matched by this pattern.) + +- [ ] **Step 4: Type-check + suite.** + +Run: `uv run mypy src/filigree/ && uv run pytest tests/ -k "generation or weft or issues_routes or dashboard_routes or adapter" -q` +Expected: mypy clean, tests PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add -A src/filigree/generations tests/ +git commit -m "refactor(rebrand): rename generations/loom -> generations/weft (axis B, T2B) + +By-identifier rename of the named API-generation module: package dir, +*Loom DTOs -> *Weft, *_to_loom adapters -> *_to_weft, create_loom_router +-> create_weft_router. The /api/loom HTTP route prefix and the \"loom\" +generation token are wire surface and flip in the wire-flip task." +``` + +**Definition of Done:** +- [ ] Package moved; all `*Loom`/`*_to_loom`/`create_loom_router` identifiers renamed +- [ ] grep clean for the identifier pattern; mypy clean; targeted suite green +- [ ] Committed + +--- + +## Task 3: T0 — `v25→v26` data migration (column + SEI prefix + rule-id prefix) + +**Files:** +- Modify: `src/filigree/db_schema.py:587` (`CURRENT_SCHEMA_VERSION = 25` → `26`) +- Modify: `src/filigree/migrations.py` (add `migrate_v25_to_v26`; register in `MIGRATIONS` dict ~line 829) +- Modify: `src/filigree/db_entity_associations.py` (projection emit key `clarion_entity_id` → `loomweave_entity_id`; `EntityAssociationRow` field rename) +- Modify: JSONL export/import (`db_meta.py:897,1389,1394` embed the column name — confirm with `grep -n "clarion_entity_id" src/filigree/db_meta.py`) +- Test: `tests/` migration test module + an entity-assoc export/import round-trip test + +**Context:** The physical SQLite column is `clarion_entity_id` (created in `migrate_v14_to_v15`). v26 renames the column, rewrites stored SEI-prefixed values, and rewrites stored finding `rule_id` prefixes — all in one version hop. SQLite ≥3.25 `ALTER TABLE ... RENAME COLUMN` rewrites the `PRIMARY KEY` reference automatically; verify in the test. + +- [ ] **Step 1: Write the failing migration test.** + +```python +def test_migrate_v25_to_v26_renames_column_and_rewrites_prefixes(tmp_path) -> None: + import sqlite3 + from filigree.migrations import migrate_v25_to_v26 + + conn = sqlite3.connect(":memory:") + # minimal v25 shape + conn.execute("CREATE TABLE entity_associations (issue_id TEXT NOT NULL, clarion_entity_id TEXT NOT NULL, " + "content_hash_at_attach TEXT NOT NULL, attached_at TEXT NOT NULL, attached_by TEXT NOT NULL, " + "PRIMARY KEY (issue_id, clarion_entity_id))") + conn.execute("CREATE TABLE findings (id TEXT PRIMARY KEY, rule_id TEXT)") + conn.execute("INSERT INTO entity_associations VALUES ('filigree-a', 'clarion:eid:deadbeef', 'h', 't', 'me')") + conn.execute("INSERT INTO findings VALUES ('f1', 'CLA-PY-UNSAFE-EVAL')") + conn.commit() + + migrate_v25_to_v26(conn) + + cols = [r[1] for r in conn.execute("PRAGMA table_info(entity_associations)").fetchall()] + assert "loomweave_entity_id" in cols and "clarion_entity_id" not in cols + eid = conn.execute("SELECT loomweave_entity_id FROM entity_associations").fetchone()[0] + assert eid == "loomweave:eid:deadbeef" # prefix rewritten, suffix preserved + rid = conn.execute("SELECT rule_id FROM findings").fetchone()[0] + assert rid == "LMWV-PY-UNSAFE-EVAL" # CLA- -> LMWV-, suffix preserved +``` + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k migrate_v25_to_v26 -v` +Expected: FAIL — `ImportError: cannot import name 'migrate_v25_to_v26'`. + +- [ ] **Step 3: Implement the migration + register it + bump the version.** + +In `migrations.py` (place after `migrate_v24_to_v25`): +```python +def migrate_v25_to_v26(conn: sqlite3.Connection) -> None: + """v25 -> v26: Loomweave/Weft rebrand data pass. + + Renames the entity-association column ``clarion_entity_id`` -> + ``loomweave_entity_id`` (the PRIMARY KEY reference is rewritten by + SQLite's RENAME COLUMN), rewrites stored SEI prefixes + ``clarion:eid:`` -> ``loomweave:eid:`` and finding rule-id prefixes + ``CLA-`` -> ``LMWV-`` in place. Suffixes are preserved. Idempotent + under re-run (guards on the source name/prefix existing). + + NOTE (Legis): rewriting the SEI prefix changes the entity_id string the + Legis HMAC was cut over, so every stored ``signature`` becomes + stale-pending-reissue. Filigree never verifies the signature, so reads + do not break; Legis re-signs in lockstep (see the rebrand epic, T0b). + """ + cols = [r[1] for r in conn.execute("PRAGMA table_info(entity_associations)").fetchall()] + if "clarion_entity_id" in cols and "loomweave_entity_id" not in cols: + conn.execute("ALTER TABLE entity_associations RENAME COLUMN clarion_entity_id TO loomweave_entity_id") + # index rename: drop the old (named in v14->v15) and re-add under the new name + conn.execute("DROP INDEX IF EXISTS ix_entity_assoc_entity") + add_index(conn, "ix_entity_assoc_entity", "entity_associations", ["loomweave_entity_id"]) + conn.execute( + "UPDATE entity_associations SET loomweave_entity_id = " + "'loomweave:eid:' || substr(loomweave_entity_id, length('clarion:eid:') + 1) " + "WHERE loomweave_entity_id LIKE 'clarion:eid:%'" + ) + conn.execute( + "UPDATE findings SET rule_id = 'LMWV-' || substr(rule_id, length('CLA-') + 1) " + "WHERE rule_id LIKE 'CLA-%'" + ) +``` +Register it: in the `MIGRATIONS` dict add `25: migrate_v25_to_v26,`. Bump `db_schema.py:587` to `CURRENT_SCHEMA_VERSION = 26`. + +> Confirm the findings table/column names with `grep -nE "CREATE TABLE findings|rule_id" src/filigree/db_schema.py` before trusting the `findings`/`rule_id` literals above; adjust the UPDATE if they differ. + +- [ ] **Step 4: Update the projection emit key + TypedDict field.** + +In `db_entity_associations.py`: the projection currently emits both `entity_id` and `clarion_entity_id` — rename the compat key to `loomweave_entity_id` (canonical `entity_id` stays primary). Rename the `EntityAssociationRow` field `clarion_entity_id` → `loomweave_entity_id`. Update the SELECT/row-mapping to read the renamed column. Update any JSONL export/import that names the column (`db_meta.py`). + +- [ ] **Step 5: Write the export/import round-trip test, then run all.** + +```python +def test_entity_assoc_export_import_roundtrips_renamed_key(db) -> None: + iid = db.create_issue(type="task", title="t").id + db.add_entity_association(iid, "loomweave:eid:abc", content_hash="h", attached_by="me") + blob = db.export_jsonl() + assert "loomweave_entity_id" in blob and "clarion_entity_id" not in blob + # re-import into a fresh DB round-trips without loss +``` +Run: +```bash +uv run pytest tests/ -k "migrate_v25_to_v26 or entity_assoc or export_import or roundtrip" -v +uv run pytest tests/ -k "migration or schema" -q +uv run mypy src/filigree/ +``` +Expected: all PASS, mypy clean. + +- [ ] **Step 6: Commit.** + +```bash +git add src/filigree/db_schema.py src/filigree/migrations.py src/filigree/db_entity_associations.py src/filigree/db_meta.py tests/ +git commit -m "feat!: v26 migration — Loomweave/Weft data rename (column, SEI prefix, rule-id) + +Rename entity_associations.clarion_entity_id -> loomweave_entity_id, +rewrite stored clarion:eid: -> loomweave:eid: and CLA- -> LMWV- finding +rule-ids in place, bump CURRENT_SCHEMA_VERSION to 26, flip the projection +emit key and JSONL export/import. Stored Legis signatures are +stale-pending-reissue by design (Legis re-signs in lockstep). + +BREAKING CHANGE: the entity-association compat key is now loomweave_entity_id; +stored SEI prefixes and finding rule-ids are rewritten to the new namespace." +``` + +**Definition of Done:** +- [ ] `migrate_v25_to_v26` renames column + rewrites both prefixes; idempotent; registered; version bumped to 26 +- [ ] Projection emit key + `EntityAssociationRow` field renamed; JSONL round-trips the new key +- [ ] Migration test + export/import test green; full migration/schema suite green; mypy clean +- [ ] Legis re-sign coupling noted in the migration docstring (Task 9 tracks the pass) +- [ ] Committed + +--- + +## Task 4: T0 — flip the `SEI_PREFIX` constant and its code-side checks + +**Files:** +- Modify: `src/filigree/sei_backfill.py:56` (`SEI_PREFIX = "clarion:eid:"` → `"loomweave:eid:"`) — all checks at `:241,248,307,339,386,466` read this constant, so they follow automatically +- Modify: `src/filigree/registry.py:168,175` (prefix checks), `cli_commands/sei.py:34`, `src/filigree/data/instructions.md:80` (doc mention) +- Test: SEI backfill test module + +**Context:** The constant is the emitter-match for stored values. Task 3 migrated the *stored rows*; this task flips the *code constant* so newly-stored/checked values use the new prefix. Do them in the same plan so stored data and code agree. + +- [ ] **Step 1: Write/adjust the failing test.** + +```python +def test_sei_prefix_is_loomweave() -> None: + from filigree.sei_backfill import SEI_PREFIX + assert SEI_PREFIX == "loomweave:eid:" + +def test_sei_value_recognised_with_new_prefix() -> None: + from filigree.sei_backfill import SEI_PREFIX + assert "loomweave:eid:abc".startswith(SEI_PREFIX) +``` + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k "sei_prefix or sei_value_recognised" -v` +Expected: FAIL — constant still `clarion:eid:`. + +- [ ] **Step 3: Flip the constant + the literal mentions.** + +Set `SEI_PREFIX = "loomweave:eid:"`. Update any hard-coded `"clarion:eid:"` literals that don't go through the constant (grep below), and the `instructions.md:80` doc line, and the `cli_commands/sei.py:34` reference. + +- [ ] **Step 4: Verify no `clarion:eid:` literal remains in code, then run.** + +Run: +```bash +grep -rn "clarion:eid:" src/filigree/ --include=*.py +uv run pytest tests/ -k "sei or backfill" -q +``` +Expected: grep returns zero (test fixtures handled in Task 8); suite PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/sei_backfill.py src/filigree/registry.py src/filigree/cli_commands/sei.py src/filigree/data/instructions.md tests/ +git commit -m "feat!: flip SEI_PREFIX to loomweave:eid: (T0) + +The emitter-match constant and its code-side prefix checks now use the +loomweave:eid: namespace, matching the v26-migrated stored values and the +Loomweave SEI emitter. + +BREAKING CHANGE: Filigree recognises loomweave:eid: SEI values, not clarion:eid:." +``` + +**Definition of Done:** +- [ ] `SEI_PREFIX = "loomweave:eid:"`; no `clarion:eid:` literal in `src/` Python; doc mention updated +- [ ] SEI/backfill suite green +- [ ] Committed + +--- + +## Task 5: T0 — `registry_backend` literal / `[clarion]` section → loomweave + config migration + +**Files:** +- Modify: `src/filigree/core.py:690,733,775-796,1049-1078` (literal `"clarion"`, `[clarion]` section keys, validation) +- Modify: the `RegistryBackend` type literal (find: `grep -rn "RegistryBackend = \|Literal\[.*clarion" src/filigree/`) — change member `"clarion"` → `"loomweave"`; `VALID_REGISTRY_BACKENDS` (`core.py:655`) derives from it via `get_args` +- Modify: `cli_commands/files.py:797` (`--to` Choice includes `clarion`), `admin.py:599`, `sei_backfill.py:158`, `registry.py:58-59` (`DEFAULT_TEST_REGISTRY_BACKENDS`, `REGISTRY_BACKEND_FEATURES` tuples) +- Add: a deployed-config migration (the literal lives in on-disk `.filigree.conf`) +- Test: config-load/validation test module + +**Context:** `registry_backend = "clarion"` and `[clarion]` live in deployed `.filigree.conf` files. A code-literal flip alone breaks reading any project already on the Clarion backend. Provide a config read-shim or migration so an existing `"clarion"` config still loads (rename-on-read to `"loomweave"`), per the hard-break-with-clean-migration posture. + +- [ ] **Step 1: Write the failing test.** + +```python +def test_registry_backend_accepts_loomweave_literal() -> None: + from filigree.core import VALID_REGISTRY_BACKENDS + assert "loomweave" in VALID_REGISTRY_BACKENDS + assert "clarion" not in VALID_REGISTRY_BACKENDS + +def test_existing_clarion_config_migrates_on_load(tmp_path) -> None: + # a deployed config still saying registry_backend="clarion" must load as loomweave + conf = tmp_path / ".filigree.conf" + conf.write_text('{"prefix":"f","version":1,"registry_backend":"clarion","clarion":{"base_url":"http://x"}}') + cfg = load_project_config(conf) # use the repo's actual loader name + assert cfg["registry_backend"] == "loomweave" + assert "loomweave" in cfg and "clarion" not in cfg +``` + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k "registry_backend_accepts_loomweave or clarion_config_migrates" -v` +Expected: FAIL — `"loomweave"` not yet a valid backend / loader doesn't rename. + +- [ ] **Step 3: Implement.** + +- Change the `RegistryBackend` literal member `"clarion"` → `"loomweave"`. `VALID_REGISTRY_BACKENDS` updates automatically. +- Replace the `"clarion"` literals in `core.py:690,733`, the `[clarion]`-section reads (`core.py:781-796`: `"clarion" not in raw`, `raw["clarion"]`, `clarion.base_url`), and the `--to` Choice / tuples with `"loomweave"`. +- Add a **rename-on-load shim** in the config loader: if `registry_backend == "clarion"`, set it to `"loomweave"`; if a `[clarion]` section is present and `[loomweave]` is not, move it. (One-shot, on read — keeps deployed configs working without manual edits.) + +- [ ] **Step 4: Verify + run.** + +Run: +```bash +grep -rnE "\"clarion\"|'clarion'|\[clarion\]" src/filigree/ --include=*.py | grep -v "migrate\|shim\|# legacy\|rename-on-load" +uv run pytest tests/ -k "config or registry_backend or validate_registry" -q +uv run mypy src/filigree/ +``` +Expected: only the deliberate legacy-shim references survive the grep; suite + mypy green. + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/core.py src/filigree/registry.py src/filigree/cli_commands/files.py src/filigree/admin.py src/filigree/sei_backfill.py src/filigree/types/ tests/ +git commit -m "feat!: rename registry_backend clarion -> loomweave + config migration (T0) + +Flip the RegistryBackend literal, [clarion] config section, and the +migrate-registry --to choice to loomweave. Add a rename-on-load shim so a +deployed .filigree.conf still saying registry_backend=clarion loads as +loomweave without manual edits. + +BREAKING CHANGE: registry_backend value and config section are now +'loomweave'; 'clarion' is accepted only via the one-shot load shim." +``` + +**Definition of Done:** +- [ ] `RegistryBackend`/`VALID_REGISTRY_BACKENDS` use `loomweave`; literals + section + `--to` choice flipped +- [ ] Deployed `clarion` config migrates on load; test proves it +- [ ] grep clean except the deliberate shim; suite + mypy green +- [ ] Committed + +--- + +## Task 6: T1 — wire flip `/api/loom` → `/api/weft` + generation token + +**Files:** +- Modify: `src/filigree/dashboard.py:104` (`protected_paths`), `:565` (docstring), `:594-597` (four `create_weft_router` mounts, `prefix="/loom"`→`"/weft"`), `:936` (docstring) +- Modify: `src/filigree/generations/weft/adapters.py:10` (gen token `"loom"`→`"weft"`) +- Modify: `dashboard_auth.py:53` generation-name branch (`rest == "loom"` / `rest.startswith("loom/")`) — **see Task 7 for the audience semantics; this task is the path token** +- Test: dashboard route test + an auth-scope test + +**Context:** This is the externally-observable HTTP contract. Hard-break: consumers hard-coded to `/api/loom/` break at 3.0.0 (Wardline already updated per the peer table). + +- [ ] **Step 1: Write the failing test.** + +```python +def test_weft_routes_mounted_and_loom_gone(client) -> None: + # a known loom endpoint now answers under /api/weft and 404s under /api/loom + assert client.get("/api/weft/ready").status_code != 404 + assert client.get("/api/loom/ready").status_code == 404 +``` +(Use a real `/api/loom/*` endpoint from `dashboard_routes/issues.py` in place of `ready`.) + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k weft_routes_mounted -v` +Expected: FAIL — routes still mounted under `/loom`. + +- [ ] **Step 3: Flip the prefix + token.** + +Change the four `prefix="/loom"` mounts to `"/weft"`; update `protected_paths` `"/api/loom/*"`→`"/api/weft/*"`; flip the generation token literal `"loom"`→`"weft"` in `adapters.py` and the negotiation branch in `dashboard_auth.py:53` (`rest == "weft"` / `rest.startswith("weft/")`); update the two docstrings. + +- [ ] **Step 4: Verify + run.** + +Run: +```bash +grep -rnE "/api/loom|prefix=\"/loom\"|== \"loom\"|startswith\(\"loom" src/filigree/ --include=*.py +uv run pytest tests/ -k "dashboard or route or auth_scope or generation" -q +``` +Expected: grep zero (docs handled in Task 8); suite PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/dashboard.py src/filigree/dashboard_auth.py src/filigree/generations/weft/adapters.py tests/ +git commit -m "feat!: flip wire surface /api/loom -> /api/weft + generation token (T1) + +Mount the named generation under /api/weft, update protected_paths and the +generation negotiation token from loom to weft. + +BREAKING CHANGE: the generation API is served at /api/weft/*; /api/loom/* is gone." +``` + +**Definition of Done:** +- [ ] Routes under `/api/weft`; `/api/loom` 404s; `protected_paths` + token flipped +- [ ] grep clean for the path/token; route + auth-scope suite green +- [ ] Committed + +--- + +## Task 7: T1 — token AUDIENCE `"loom"` → `"weft"` (security-sensitive) + +**Files:** +- Modify: `src/filigree/dashboard_auth.py:53` (audience match) + `LIVING_FEDERATION_ALIASES`/`CLASSIC_FEDERATION_ALIASES` if they encode the audience +- Test: auth/token-validation test module + +**Context:** 🔴 The audience claim gates federation auth. Both sides must agree **and tokens must be re-issued** with `aud="weft"` — a mismatch fails auth federation-wide. The code change is small; the operational re-issue is a deploy step (note it; it is not a code commit). Sequenced **after** Task 6 because both touch `dashboard_auth.py:53`. + +- [ ] **Step 1: Write the failing test.** + +```python +def test_token_audience_is_weft(client_with_token) -> None: + # a token minted with aud="weft" is accepted; aud="loom" is rejected + assert client_with_token(aud="weft").get("/api/weft/ready").status_code != 401 + assert client_with_token(aud="loom").get("/api/weft/ready").status_code == 401 +``` +(Adapt to the repo's actual token-minting test helper and audience-check location.) + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k token_audience_is_weft -v` +Expected: FAIL — `loom` audience still accepted. + +- [ ] **Step 3: Flip the audience.** + +Change the accepted audience value `"loom"`→`"weft"` wherever the token's `aud` is validated. If `LIVING_FEDERATION_ALIASES`/`CLASSIC_FEDERATION_ALIASES` carry audience semantics (not just path aliases), update accordingly — otherwise leave them (they are the scan-results/observations path aliases, unrelated to audience). + +- [ ] **Step 4: Run.** + +Run: `uv run pytest tests/ -k "auth or token or audience or federation" -q` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/dashboard_auth.py tests/ +git commit -m "feat!: token audience loom -> weft (T1, security-sensitive) + +The federation token audience claim is now weft. Deployments MUST re-issue +tokens with aud=weft; a loom-audience token is rejected. + +BREAKING CHANGE: federation tokens must carry aud=weft." +``` + +**Operational note (NOT a code step):** schedule token re-issuance (`aud: loom`→`weft`) with the 3.0.0 deploy. Record on the rebrand epic. + +**Definition of Done:** +- [ ] Accepted audience is `weft`; `loom`-aud token rejected; test proves both +- [ ] Auth suite green; re-issuance recorded as a deploy step on the epic +- [ ] Committed + +--- + +## Task 8: T1 — federation token env var `CLARION_LOOM_TOKEN` → `WEFT_TOKEN` + +**Files:** +- Modify: `src/filigree/registry.py:56` (`DEFAULT_LOOMWEAVE_TOKEN_ENV = "CLARION_LOOM_TOKEN"` → `"WEFT_TOKEN"`) — note the *constant* was renamed in Task 1; this changes its *value* +- Modify: `core.py:50,1155,1161` (references / log lines naming the env var), `registry.py:18` (docstring) +- Test: token-resolution test module + +**Context:** 🔴 Deployment-set — breaks every deployment env, not just code. The hub locked the short form **`WEFT_TOKEN`** (not `LOOMWEAVE_WEFT_TOKEN`). Hard-break: no fallback read of the old var. + +- [ ] **Step 1: Write the failing test.** + +```python +def test_default_token_env_is_weft_token(monkeypatch) -> None: + from filigree.registry import DEFAULT_LOOMWEAVE_TOKEN_ENV + assert DEFAULT_LOOMWEAVE_TOKEN_ENV == "WEFT_TOKEN" + +def test_token_resolved_from_weft_token_env(monkeypatch) -> None: + monkeypatch.setenv("WEFT_TOKEN", "secret") + monkeypatch.delenv("CLARION_LOOM_TOKEN", raising=False) + # resolve via the repo's actual token-resolution path; expect "secret" +``` + +- [ ] **Step 2: Run to verify failure.** + +Run: `uv run pytest tests/ -k "default_token_env_is_weft or token_resolved_from_weft" -v` +Expected: FAIL — default still `CLARION_LOOM_TOKEN`. + +- [ ] **Step 3: Flip the value + references.** + +Set `DEFAULT_LOOMWEAVE_TOKEN_ENV = "WEFT_TOKEN"`. Update the docstring at `registry.py:18-20` and the `core.py` log/reference lines that name `CLARION_LOOM_TOKEN`. **No fallback** to the old var (hard-break). + +- [ ] **Step 4: Verify + run.** + +Run: +```bash +grep -rn "CLARION_LOOM_TOKEN" src/filigree/ --include=*.py +uv run pytest tests/ -k "token or registry_auth or env" -q +``` +Expected: grep zero in `src/` Python (docs handled in Task 8 below / CLAUDE.md in Task 9); suite PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/filigree/registry.py src/filigree/core.py tests/ +git commit -m "feat!: federation token env var CLARION_LOOM_TOKEN -> WEFT_TOKEN (T1) + +The default federation Bearer-token env var is now WEFT_TOKEN (hub-locked +short form). No fallback to the old name. + +BREAKING CHANGE: set WEFT_TOKEN; CLARION_LOOM_TOKEN is no longer read." +``` + +**Operational note:** update every deployment env (`CLARION_LOOM_TOKEN`→`WEFT_TOKEN`) with the deploy. Record on the epic. + +**Definition of Done:** +- [ ] Default env var value is `WEFT_TOKEN`; no `CLARION_LOOM_TOKEN` in `src/` Python; no fallback +- [ ] Token-resolution suite green +- [ ] Committed + +--- + +## Task 9: T3 — docs, agent instructions, CHANGELOG, CI, test fixtures, dashboard JS + +**Files:** +- `src/filigree/data/instructions.md` (Clarion/SEI/error-code mentions — **leave the quarantined `CLARION_*` error codes**), `src/filigree/skills/filigree-workflow/SKILL.md` +- Repo `CLAUDE.md` (entity-association ADR-029 blurb, `Clarion` mentions — **leave the `CLARION_REGISTRY_VERSION_MISMATCH` error-code list entry**, it's quarantined) +- ADRs: ADR-002, ADR-014, ADR-017, ADR-029 (note the rename; do not rewrite shipped decisions — add a rebrand note) +- `docs/federation/contracts.md`, `registry-backend-launch-runbook.md` +- `README.md`, `ROADMAP.md` (Loom→Weft framing, `/api/loom`→`/api/weft`) +- `CHANGELOG.md` — **only the `[3.0.0]` section**: add a `### Changed (BREAKING)` rebrand entry; leave shipped-version history untouched +- Test fixtures: `tests/fixtures/contracts/{classic,loom}/scan-results.json` (`CLA-PY-UNSAFE-EVAL`→`LMWV-PY-UNSAFE-EVAL`), `tests/_fakes/clarion_http.py`→`weave`/`loomweave` fake server, `tests/federation/test_sei_oracle_live_clarion.py`, `tests/integration/test_clarion_*`, `tests/unit/test_clarion_capabilities_probe.py` +- `.github/workflows/ci.yml` (job names, env, test selectors) +- Dashboard JS (**separate biome gate**): `static/js/views/detail.js`, `static/js/app.js`, `static/dashboard.html` (`clarionRotationBanner`, generation labels) + +- [ ] **Step 1: Update fixtures + fakes (these are exercised by tests).** + +Rename fixture rule-ids `CLA-`→`LMWV-`, rename `tests/_fakes/clarion_http.py`→`loomweave_http.py` and its server class, rename `test_clarion_*` files in lockstep with the code they exercise. Run the suite after to catch breaks: `uv run pytest --tb=short`. + +- [ ] **Step 2: Update agent-facing instructions + CLAUDE.md.** + +Rename `Clarion`→`Loomweave` and `Loom`→`Weft` framing in `instructions.md`, `SKILL.md`, `CLAUDE.md`. **Preserve** the quarantined `CLARION_*` error-code names in the error-codes lists (they are NOT renamed in this plan). + +- [ ] **Step 3: Update README/ROADMAP/ADRs/federation docs + CHANGELOG `[3.0.0]`.** + +Add the breaking-change rebrand entry to CHANGELOG `[3.0.0]`: +```markdown +### Changed (BREAKING) + +- **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 key + `clarion_entity_id`→`loomweave_entity_id`, the SEI prefix + `clarion:eid:`→`loomweave:eid:`, finding rule-ids `CLA-`→`LMWV-`, the token + audience `loom`→`weft`, and the token env var `CLARION_LOOM_TOKEN`→`WEFT_TOKEN`. + No compatibility aliases. Deployments must re-issue tokens (aud=weft) and set + WEFT_TOKEN. The `registry_backend` value/section is now `loomweave` (a deployed + `clarion` config is migrated on load). Stored Legis signatures are + stale-pending-reissue until Legis re-signs over the renamed entity_ids. +``` + +- [ ] **Step 4: Update dashboard JS + run biome.** + +Rename `clarionRotationBanner`→`loomweaveRotationBanner` and generation labels in the JS/HTML. Then (per CLAUDE.md, JS-only gate): +```bash +npx biome lint src/filigree/static/js/ +npx biome format src/filigree/static/js/ +``` + +- [ ] **Step 5: Full verification + commit.** + +Run the full pipeline (Step "Pre-merge verification" below), then: +```bash +git add -A +git commit -m "docs(rebrand): Loomweave/Weft across docs, instructions, fixtures, CI, JS (T3) + +Rename Clarion->Loomweave and Loom->Weft in agent instructions, CLAUDE.md, +README/ROADMAP/ADRs, federation docs, test fixtures/fakes, CI, and dashboard +JS. CHANGELOG [3.0.0] gets the breaking rebrand entry. Quarantined CLARION_* +error codes and the loom:// URI scheme are deliberately left for a follow-up." +``` + +**Definition of Done:** +- [ ] Fixtures/fakes/tests renamed; full suite green +- [ ] Instructions/CLAUDE.md/README/ROADMAP/ADRs/federation docs updated; quarantined error codes preserved +- [ ] CHANGELOG `[3.0.0]` breaking entry added; shipped history untouched +- [ ] Dashboard JS updated; biome lint + format clean +- [ ] Committed + +--- + +## Task 10: Legis re-sign coordination (out-of-band, tracking only) + +**Not a code change in this repo.** The v26 SEI-prefix rewrite (Task 3) changes the `entity_id` string the Legis HMAC was cut over, so every stored `signature` is stale-pending-reissue. + +- [ ] Notify the Legis owner: re-cut the HMAC for every governed binding over the renamed `loomweave:eid:` entity_ids, in lockstep with the 3.0.0 deploy. +- [ ] Record the coordination on subtask `filigree-2cf022fff2` (T0b) and the epic `filigree-1d08ffb493`. +- [ ] Confirm the documented transient is acceptable for the deploy window (Filigree never verifies signatures, so reads do not break; only downstream governance re-verification is affected until Legis re-signs). + +**Definition of Done:** +- [ ] Legis owner notified; T0b updated with the re-sign requirement and the lockstep deploy dependency + +--- + +## Pre-merge verification (run after Task 9, before declaring done) + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +make coverage-floors +# JS gate (Task 9 touched JS): +npx biome lint src/filigree/static/js/ +npx biome format src/filigree/static/js/ +``` +Expected: all green. Then a final brand sweep — these should return only the deliberate quarantine survivors: +```bash +grep -rnE "Clarion|clarion" src/filigree/ --include=*.py | grep -vE "CLARION_REGISTRY_VERSION_MISMATCH|CLARION_OUT_OF_SYNC|migrate_v14_to_v15|rename-on-load|# legacy" +grep -rnE "/api/loom|clarion:eid:|CLARION_LOOM_TOKEN|clarion_entity_id" src/filigree/ --include=*.py +``` +Expected: first grep — only the quarantined error codes + historical-migration column reference; second grep — zero. + +## Self-review checklist (run once, after the plan executes) + +- [ ] Every locked-contract row (table at top) has a task that flips it: routes→T6, column→T3, SEI prefix→T3+T4, rule-id→T3, env var→T8, audience→T7, registry_backend→T5, code identifiers→T1+T2. +- [ ] Every quarantined item is still in its old form (error codes, `loom://`, capabilities probe, Legis surface) — confirmed by the verification greps. +- [ ] No `backward`-style leftover: the brand sweep greps are clean modulo the documented allowlist. +- [ ] Legis re-sign coupling is documented in the v26 docstring and tracked (Task 10). + +## Handoff / sequencing notes + +- **Co-sequence with MCP namespacing** (`filigree-7771610917`) in the same 3.0.0 cut so consumers absorb one cutover, not two. That is a *separate* plan; coordinate the merge order with its owner. +- **Operational deploy steps** (not code): re-issue tokens with `aud=weft`; set `WEFT_TOKEN` in every env; Legis re-sign pass. Record all three on epic `filigree-1d08ffb493`. +- **Issue mapping:** Task 1→`filigree-0d403dc684` (T2A), Task 2→`filigree-cda5448d48` (T2B), Tasks 3–5→`filigree-e0896844cd` (T0), Tasks 6–8→`filigree-648e6460d4` (T1), Task 9→`filigree-44a56a8912` (T3), Task 10→`filigree-2cf022fff2` (T0b). The gate `filigree-23709c5975` (G0) closes once the WEFT_TOKEN correction + the 4 residual quarantines are recorded. diff --git a/docs/plans/2026-06-06-pr52-section4-3.0.0-items.md b/docs/plans/2026-06-06-pr52-section4-3.0.0-items.md new file mode 100644 index 00000000..609689fe --- /dev/null +++ b/docs/plans/2026-06-06-pr52-section4-3.0.0-items.md @@ -0,0 +1,266 @@ +# PR #52 §4 breaking-bundle — 3.0.0 items Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **REQUIRED SUB-SKILL:** Use superpowers:test-driven-development for every task. + +**Goal:** Land the three §4 breaking-bundle items confirmed for 3.0.0 in the D3 decision (2026-06-06): remove the deprecated `get_stats` alias keys, add `safe_message` parity to the two missing error classes, and replace the internal `backward` boolean with a `TransitionMode` enum. + +**Decision context (D3):** the major-version boundary is the cheap window for wire-breaking changes. `get_stats` key removal is wire-breaking → do now. `safe_message` is additive → cheap, do now. `TransitionMode` is internal-only (no MCP/CLI/wire exposure) — the owner elected to include it in 3.0.0 anyway. **`clarion_entity_id` rename is DEFERRED to 3.1.0** (separate issue `filigree-45d76e71bb`; needs a v26 migration + JSONL dual-read shim — not in this plan). + +**Tracking:** existing issues, re-scoped to 3.0.0 — `filigree-e4181ae767` (get_stats), `filigree-d25e75cebf` (safe_message), `filigree-9b4bb6e52e` (TransitionMode). + +**Prerequisites:** +- Branch `release/3.0.0` (no branch creation/switch without owner approval). +- Baseline green: `uv run pytest --tb=short`. + +**Task order:** Task 1 (get_stats) and Task 2 (safe_message) are independent. Task 3 (TransitionMode) touches `types/api.py`, which Task 2 also touches (`safe_message` adds a property; `TransitionMode` changes `InvalidTransitionError.__init__`) — **sequence Task 2 before Task 3** so they don't churn the same class. + +--- + +## ⚠️ Task 1 gate: cross-product confirmation (blocking, do first) + +Per CLAUDE.md (no hidden cross-product wire work): before removing the `get_stats` +keys, **confirm with Clarion and Wardline owners** that no released sibling reads +`status_name_counts` / `status_category_counts` from the `get_stats` MCP tool or +the HTTP stats endpoint. In-repo consumers are fully mapped (Task 1) and the +dashboard JS already uses canonical `by_category`; the unknown is external. This +is tracked as a blocking dependency on the get_stats issue. **Do not merge Task 1 +until that confirmation lands.** (Task 1 can be *implemented* in parallel; it just +cannot ship unconfirmed.) + +--- + +## Task 1: Remove deprecated `get_stats` alias keys + +**Files:** +- Modify: `src/filigree/db_meta.py:429-435` (drop the two deprecated keys + their comment) +- Modify: `src/filigree/types/planning.py:157-158` (drop the two TypedDict fields) +- Modify: `src/filigree/mcp_tools/meta.py:336` (deprecation docstring referencing the keys) +- Tests to update (they currently assert the keys EXIST): `tests/cli/test_query_commands.py:300-301`, `tests/util/test_type_contracts.py:767-774`, `tests/mcp/test_tools.py:228-229,780-787` + +**Context:** `status_name_counts` / `status_category_counts` are exact duplicates of `by_status` / `by_category` (`db_meta.py:427-428`), retained as deprecated aliases for "the next major." 3.0.0 is that major. + +**Step 1: Write/adjust the failing test** + +Flip the existing assertions to assert ABSENCE. E.g. in `tests/util/test_type_contracts.py` (and the CLI/MCP equivalents): +```python +def test_get_stats_drops_deprecated_alias_keys() -> None: + stats = db.get_stats() + assert "by_status" in stats and "by_category" in stats # canonical retained + assert "status_name_counts" not in stats # deprecated removed + assert "status_category_counts" not in stats +``` +Update the spots at `test_query_commands.py:300-301` and `test_tools.py:228-229,780-787` that currently read/assert the deprecated keys to read the canonical ones (or assert absence). + +**Step 2: Run to verify failure** + +Run: `uv run pytest tests/util/test_type_contracts.py -k deprecated_alias -v` + +Expected: FAIL — the keys are still emitted. + +**Step 3: Implement — delete the keys, the fields, and the docstring reference** + +- `db_meta.py`: delete lines 434-435 (`"status_name_counts": ...`, `"status_category_counts": ...`) and the now-stale DEPRECATED comment block at 429-433. +- `types/planning.py`: delete the `status_name_counts` / `status_category_counts` fields at 157-158. +- `mcp_tools/meta.py:336`: remove the sentence describing the deprecated keys from the tool docstring. + +**Step 4: Run to verify pass** + +Run: `uv run pytest tests/ -k "stats or summary or type_contract or query_commands" -q && uv run mypy src/filigree/` + +Expected: PASS (mypy confirms no remaining reader of the dropped TypedDict fields). + +**Step 5: Commit** + +```bash +git add src/filigree/db_meta.py src/filigree/types/planning.py src/filigree/mcp_tools/meta.py tests/ +git commit -m "feat!: remove deprecated get_stats alias keys (3.0.0 breaking) + +status_name_counts / status_category_counts were exact duplicates of +by_status / by_category, retained as deprecated aliases for the next major. +Remove them from the builder, the StatsResult TypedDict, and the MCP tool +docstring. Consumers read by_status / by_category. + +BREAKING CHANGE: get_stats / get_summary / HTTP stats no longer emit +status_name_counts or status_category_counts." +``` + +**CHANGELOG:** add to `[3.0.0]` breaking changes. **Update OpenAPI/contract fixtures** if the stats response shape is captured in any fixture (grep for the key names under `tests/` and `docs/`). + +**Definition of Done:** +- [ ] Keys removed from `db_meta.py`, `types/planning.py`, and `mcp_tools/meta.py` docstring +- [ ] Tests assert absence + canonical keys; mypy clean +- [ ] CHANGELOG [3.0.0] breaking-change entry +- [ ] Cross-product confirmation recorded on the issue (blocking merge) +- [ ] Committed + +--- + +## Task 2: Add `safe_message` parity to `InvalidTransitionError` and `ClaimConflictError` + +**Files:** +- Modify: `src/filigree/types/api.py` (`ClaimConflictError` ~582-597; `InvalidTransitionError` ~647-693) +- Test: `tests/` api/types test module (`grep -rl "safe_message\|InvalidTransitionError" tests/`) + +**Context:** `safe_message` is the untrusted-surface serialization hook (2.1.0 §1.2) — consumed via `getattr(exc, "safe_message", None)` (e.g. `dashboard.py:916`, `mcp_tools/entities.py:204`). It exists on three project/foreign-DB errors (`core.py:206,234,277`) but not on `InvalidTransitionError` or `ClaimConflictError`, so those leak their full `str(exc)` (which can include issue IDs / actor names) to untrusted surfaces. Add the property for parity. + +**Step 1: Write the failing test** + +```python +from filigree.types.api import InvalidTransitionError, ClaimConflictError + +def test_invalid_transition_error_has_safe_message() -> None: + exc = InvalidTransitionError("task", "in_progress", to_state="closed") + assert exc.safe_message == "Invalid status transition for this issue type" # no IDs/actors leaked + +def test_claim_conflict_error_has_safe_message() -> None: + exc = ClaimConflictError("filigree-abc", observed="alice", expected="bob") + assert exc.safe_message == "Issue is assigned to a different actor" # no names leaked +``` +(Pick safe-message wording consistent with the three existing `core.py` messages — short, ID/actor-free.) + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k safe_message -v` + +Expected: FAIL — `AttributeError: 'InvalidTransitionError' object has no attribute 'safe_message'`. + +**Step 3: Implement — add the property to both classes** + +Mirror the `core.py` pattern (`@property def safe_message(self) -> str: return "..."`). On `ClaimConflictError`: +```python + @property + def safe_message(self) -> str: + return "Issue is assigned to a different actor" +``` +On `InvalidTransitionError`: +```python + @property + def safe_message(self) -> str: + return "Invalid status transition for this issue type" +``` + +**Step 4: Run to verify pass** + +Run: `uv run pytest -v && uv run pytest tests/ -k "dashboard or mcp" -q` + +Expected: PASS — and any surface using `getattr(exc, "safe_message", ...)` now gets the safe string for these two errors. + +**Step 5: Commit** + +```bash +git add src/filigree/types/api.py tests/ +git commit -m "feat: add safe_message parity to transition/claim-conflict errors + +InvalidTransitionError and ClaimConflictError lacked the safe_message property +the untrusted-surface serializers read (2.1.0 §1.2), so they leaked full +str(exc) (issue IDs, actor names). Add ID/actor-free safe messages, mirroring +the three core.py project-state errors." +``` + +**Definition of Done:** +- [ ] `safe_message` on both error classes, ID/actor-free +- [ ] Tests assert the safe strings +- [ ] Committed (BEFORE Task 3 — same file) + +--- + +## Task 3: Replace the `backward` boolean with a `TransitionMode` enum + +**Files:** +- Modify: `src/filigree/types/api.py` (define `TransitionMode`; `InvalidTransitionError.__init__` ~675) +- Modify: `src/filigree/templates.py:962` (`validate_transition`) + call sites `:998,1001` +- Modify: `src/filigree/db_issues.py:749` (`update_issue`) + call sites `:853,866,944,1182,1224,1615,1637` +- Modify: `src/filigree/db_base.py:388` (`DBMixinProtocol.update_issue`) +- Test: workflow/transition test module + +**Context:** `backward: bool` is **internal Python API only** (verified: no MCP/CLI/wire exposure), so 3.0.0 can replace it outright rather than alias. The enum makes the call sites self-documenting (`mode=TransitionMode.BACKWARD` vs a bare `True`). + +**Step 1: Write the failing test** + +```python +from filigree.types.api import TransitionMode + +def test_transition_mode_enum_exists_and_has_forward_backward() -> None: + assert TransitionMode.FORWARD.value == "forward" + assert TransitionMode.BACKWARD.value == "backward" + +def test_update_issue_accepts_transition_mode(db) -> None: + issue = db.create_issue(type="task", title="t") + db.update_issue(issue.id, status="in_progress") # forward (default) + # a backward transition uses the enum, not a bool: + db.update_issue(issue.id, status="open", mode=TransitionMode.BACKWARD) + assert db.get_issue(issue.id).status == "open" +``` + +**Step 2: Run to verify failure** + +Run: `uv run pytest -k transition_mode -v` + +Expected: FAIL — `ImportError: cannot import name 'TransitionMode'` (and `update_issue` rejects `mode=`). + +**Step 3: Implement** + +Define the enum in `types/api.py` (near `InvalidTransitionError`): +```python +class TransitionMode(Enum): + """Direction of a status transition. Replaces the historical `backward` bool.""" + FORWARD = "forward" + BACKWARD = "backward" +``` +Then, in each of the four signatures, replace `backward: bool = False` with `mode: TransitionMode = TransitionMode.FORWARD`, and inside each body replace `if backward:` / `backward=...` reads with `if mode is TransitionMode.BACKWARD:`. Update the call sites: +- `templates.py:998,1001`, `db_issues.py:853,866,944,1182,1224,1615,1637`, `types/api.py:682,708`: any `backward=True` → `mode=TransitionMode.BACKWARD`; any `backward=False` → drop (default) or `mode=TransitionMode.FORWARD`; pass-throughs become `mode=mode`. +- `InvalidTransitionError.__init__`: replace the `backward: bool` param with `mode: TransitionMode = TransitionMode.FORWARD`; update the two construction/re-raise sites (`api.py:682,708`) and any message text that branched on `backward`. + +Let mypy drive completeness — it will flag every remaining `backward=` keyword and every `if backward` read. + +**Step 4: Run to verify pass** + +Run: `uv run mypy src/filigree/ && uv run pytest tests/ -k "transition or workflow or update_issue or template" -q` + +Expected: mypy clean (no stray `backward`), tests PASS. + +**Step 5: Verify no `backward` boolean remains** + +Run: `grep -rn "backward" src/filigree/ | grep -v "TransitionMode\|# "` + +Expected: no `backward: bool` / `backward=` hits remain (comments/docstrings referencing the old name are fine to update or leave). + +**Step 6: Commit** + +```bash +git add src/filigree/types/api.py src/filigree/templates.py src/filigree/db_issues.py src/filigree/db_base.py tests/ +git commit -m "refactor!: replace backward bool with TransitionMode enum (3.0.0) + +The internal transition direction was a bare backward: bool across +validate_transition / update_issue / DBMixinProtocol / InvalidTransitionError. +Replace with a TransitionMode{FORWARD,BACKWARD} enum for self-documenting call +sites. Internal API only (no MCP/CLI/wire exposure), so replaced outright. + +BREAKING CHANGE: update_issue/validate_transition take mode=TransitionMode.* +instead of backward=bool (internal Python API)." +``` + +**Definition of Done:** +- [ ] `TransitionMode` enum defined +- [ ] All four signatures + all call sites migrated; no `backward` boolean remains (grep clean) +- [ ] mypy clean; transition/workflow tests pass +- [ ] CHANGELOG [3.0.0] note (internal API breaking change) +- [ ] Committed (AFTER Task 2) + +--- + +## Pre-merge verification + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +make coverage-floors +``` + +## Handoff notes +- **Task 1 cannot merge** until the Clarion/Wardline cross-product confirmation lands (tracked as a blocking dep on `filigree-e4181ae767`). +- **clarion_entity_id rename is NOT here** — deferred to 3.1.0 on `filigree-45d76e71bb` with a v26-migration + JSONL dual-read-shim requirement. +- Sequence Task 2 → Task 3 (both touch `types/api.py`). diff --git a/docs/plans/close-on-fixed-cascade.md b/docs/plans/close-on-fixed-cascade.md new file mode 100644 index 00000000..29cb3741 --- /dev/null +++ b/docs/plans/close-on-fixed-cascade.md @@ -0,0 +1,168 @@ +# Plan — close-on-fixed cascade during scan-results ingest (Filigree ask #3) + +> **SUPERSEDED (2026-06-05).** This first-pass plan covered only the close +> cascade and misdiagnosed the real product gap (clean files are never swept) as +> a test-construction rule. The implemented, two-part plan — close cascade **plus** +> the prerequisite `scanned_paths`-driven sweep — and its no-decoy acceptance +> tests live at `/home/john/.claude/plans/fluttering-dreaming-tower.md`. The work +> shipped per that plan; this file is kept for history only. + +Re-verified against working tree at HEAD `54cdd65`. Source is schema **v23** +(`db_schema.py:575`); the session-start warning is only the *globally +installed* `filigree` CLI being stale at v22 — irrelevant to `uv run` tests. + +## Problem + +Reopen-on-regress is wired into ingest; close-on-fixed is not. After an agent +fixes code and re-scans, `_mark_unseen_findings` flips the finding to +`unseen_in_latest` but the linked issue stays open. The close helper +(`_close_issue_for_fixed_finding`) has exactly one caller — the age-gated +`clean_stale_findings` sweep — which Wardline never invokes (it drives +everything through `POST /api/loom/scan-results`). + +Fix: a close cascade symmetric to the existing reopen cascade, fired from +`process_scan_results`. + +## Key facts verified + +- Service indirection is a thin wrapper: `_close_issue_for_fixed_finding` + (db_files.py:1825) → `FindingIssueCascadeService.close_fixed_finding` + (finding_issue_cascade.py:70) → `store._close_issue_for_fixed_finding_tx` + (db_files.py:1834). **The status guard at db_files.py:1846 is the live, only + copy** — the ask's pointer is correct, not dead code. +- `_mark_unseen_findings` is a `@staticmethod` (db_files.py:1323) with a single + caller; `_ingest_resolved_findings` has a single caller. Both signature + changes are safe. +- `TERMINAL_FINDING_STATUSES = {"fixed", "false_positive"}` (db_files.py:71). +- ⚠️ **`_mark_unseen_findings` only sweeps files present in the current batch.** + It iterates `seen_finding_ids` (keyed by `file_id`, populated only from this + batch). A file with no finding in the batch is never visited. This shapes the + tests (see below) and is the single easiest thing to get wrong. + +## Implementation + +### Step 1 — capture resolved pairs in `_mark_unseen_findings` (db_files.py:1323) + +Add a keyword-only `resolved: set[tuple[str, str]]` param. For each `(fid, fids)` +in `seen_finding_ids`, **before** the existing UPDATE, SELECT the rows about to +genuinely transition and collect `(finding_id, issue_id)`: + +```python +rows = conn.execute( + f"SELECT id, issue_id FROM scan_findings " + f"WHERE file_id = ? AND scan_source = ? AND issue_id IS NOT NULL " + f"AND status NOT IN ({terminal_ph}) " + f"AND status != 'unseen_in_latest' " # only real open/new → unseen + f"AND id NOT IN ({placeholders})", + [fid, scan_source, *terminal, *fids], +).fetchall() +for row in rows: + resolved.add((row["id"], str(row["issue_id"]))) +``` + +The `status != 'unseen_in_latest'` clause is load-bearing: it stops a finding +that was *already* unseen from a prior scan re-firing the close every batch +(acceptance #5, idempotency). Run the existing UPDATE unchanged afterward. + +### Step 2 — thread `resolved` through the call chain + +- `process_scan_results` (db_files.py:1417): add `resolved: set[tuple[str, str]] = set()` + beside `regressed_issue_ids`, pass it into `_ingest_resolved_findings`. +- `_ingest_resolved_findings` (db_files.py:1494): add the `resolved` param; + `resolved.clear()` on entry, right next to `regressed_issue_ids.clear()` + (db_files.py:1528) — `@_retry_busy` re-runs the method, so a rolled-back + transient BUSY must not double-accumulate. Pass `resolved=resolved` into the + `_mark_unseen_findings` call (db_files.py:1560). Only populated when + `mark_unseen=True`, which is exactly Wardline's path. + +### Step 3 — widen the close-tx status guard (db_files.py:1846) + +```python +if finding is None or finding["status"] not in ("fixed", "unseen_in_latest"): + return False +``` + +Safe for both callers. clean-stale sets `fixed` before the cascade reads (still +passes). The post-commit race guard still holds: if ingest reopened the finding +to `open` between commit and cascade, status is `open` → skipped. (Verified: +`test_reingest_between_sweep_and_cascade_does_not_close_issue` drives the +finding to `open`, which fails the widened set → no close. No behavior change.) + +### Step 4 — close post-commit, beside the reopen loop (after db_files.py:1464) + +```python +warnings_before_close = len(stats["warnings"]) +closed_issue_ids = [ + issue_id + for finding_id, issue_id in sorted(resolved) + if self._close_issue_for_fixed_finding(finding_id, issue_id, warnings=stats["warnings"]) +] +for warning in stats["warnings"][warnings_before_close:]: + logger.warning("finding→issue close cascade: %s", warning) +if closed_issue_ids: + logger.info( + "finding→issue cascade: closed %d issue(s) on fix (scan_source=%r): %s", + len(closed_issue_ids), scan_source, ", ".join(closed_issue_ids), + ) +``` + +`_close_issue_for_fixed_finding` already: stamps `FINDING_CASCADE_MARKER` (so +regress can reopen it), no-ops on a `done`-category issue (terminal human +decisions preserved, db_files.py:1850), runs its own `BEGIN IMMEDIATE` +(best-effort, never fails ingest), and records reconciliation-debt on failure. + +Reopen runs before close; the two sets are normally disjoint (a finding is +either in the batch → maybe-regressed, or absent → maybe-resolved). Do **not** +add a comment claiming strict disjointness — a single issue with two findings +in opposite directions in one batch resolves by loop order (ends closed). Rare, +the existing reopen path shares the ambiguity, and the task scopes to symmetry. + +## Tests — `tests/core/test_finding_issue_cascade.py`, no `clean_stale_findings` call + +Add a `TestCloseOnFixedFromIngest` class. Mirror `_wln` / `_ingest` / +`_is_done` helpers already in the file. + +**Same-file batch is mandatory.** Because the sweep only visits batched files, +F at `src/a.py` resolves only if the re-POST contains another finding **in +`src/a.py`** (different fingerprint). A decoy in `src/b.py` would leave +`src/a.py` unvisited and F open — a false failure. + +1. **Immediate close from ingest** — ingest F (`fp-fix`, `src/a.py`), promote → + I open. Re-POST `mark_unseen=True` with a sibling finding (`fp-other`, + `src/a.py`) and F absent. Assert F is `unseen_in_latest` AND `_is_done(I)`. +2. **Reopen still works** — from (1), re-POST including F (`fp-fix`, `src/a.py`). + Assert `not _is_done(I)` and F `open`. +3. **Terminal human decision preserved** — promote, `close_issue(actor="human", + force=True)`, then run the (1) batch (F absent, sibling present): I is **not** + reopened by regress nor re-closed by the cascade (the `== "done"` guard wins). +4. **Idempotent with clean-stale** — after (1), call `clean_stale_findings`; + no error, no double-transition (`closed_issue_ids == []` second time). +5. **No spurious close** — re-POST including *all* prior fingerprints; `resolved` + is empty → nothing closed, I stays open. + +Optional: a cascade-close-failure-surfaced-in-warnings test mirroring the +existing reopen-failure test (monkeypatch `close_issue` to raise; assert the +warning rides `stats["warnings"]` and is logged). + +TDD: write all five, watch #1 fail **for the right reason** (I stays open with +correct same-file setup) before implementing. + +## Verification (memory: pre-push CI) + +```bash +uv run pytest tests/core/test_finding_issue_cascade.py tests/core/test_scans.py \ + tests/core/test_scan_finding_fingerprint.py --tb=short +uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +``` + +## Non-asks (do not touch) + +No new route or payload (internal to `process_scan_results`; cascade is logged, +not surfaced — the envelope is a frozen passthrough). No change to asks #1/#2. +No Wardline change (it already POSTs `mark_unseen=True`). clean-stale is not +retired — it still archives stale rows to `fixed`; its close then hits the +`== "done"` guard and no-ops. Ingest closes eagerly; clean-stale archives +eventually. +``` diff --git a/docs/plans/2026-04-26-2.0-phase-d-mcp-forward-migration.md b/docs/plans/completed/2026-04-26-2.0-phase-d-mcp-forward-migration.md similarity index 100% rename from docs/plans/2026-04-26-2.0-phase-d-mcp-forward-migration.md rename to docs/plans/completed/2026-04-26-2.0-phase-d-mcp-forward-migration.md diff --git a/docs/plans/2026-04-28-2.0-phase-e-cli-forward-migration.md b/docs/plans/completed/2026-04-28-2.0-phase-e-cli-forward-migration.md similarity index 100% rename from docs/plans/2026-04-28-2.0-phase-e-cli-forward-migration.md rename to docs/plans/completed/2026-04-28-2.0-phase-e-cli-forward-migration.md diff --git a/docs/plans/2026-04-28-2.0-phase-f-release-readiness.md b/docs/plans/completed/2026-04-28-2.0-phase-f-release-readiness.md similarity index 100% rename from docs/plans/2026-04-28-2.0-phase-f-release-readiness.md rename to docs/plans/completed/2026-04-28-2.0-phase-f-release-readiness.md diff --git a/docs/plans/2026-05-18-2.1.0-release-prep.md b/docs/plans/completed/2026-05-18-2.1.0-release-prep.md similarity index 100% rename from docs/plans/2026-05-18-2.1.0-release-prep.md rename to docs/plans/completed/2026-05-18-2.1.0-release-prep.md diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 2657df09..ef82828d 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,35 +1,31 @@ /* ========================================================================== - Filigree — Loom design system + Filigree accent, layered on Material. - - Strategy (shared with Wardline so the suite stays visually coherent): - - Material owns the light/dark toggle. We do NOT add a custom toggle and we - do NOT use light-dark()/[data-theme]; we map semantic tokens under - Material's own scheme selectors ([data-md-color-scheme="default"|"slate"]) - and override Material's --md-primary/--md-accent variables there. - - The OKLCH ink ramp + spacing/type/radius scale are the shared Loom system, - identical to Wardline. The ONLY divergence is the accent hue: - Wardline = teal (~195); Filigree = gold/amber (~82) — "filigree" is fine - ornamental gold metalwork. + Filigree — thin identity layer over the shared Weft docs theme. + + Layered ON TOP OF the vendored `weft-mkdocs.css` (loaded first in mkdocs.yml). + weft-mkdocs.css owns the base chrome: Material --md-* surface / text / code / + footer tokens per scheme, the JetBrains Mono / Space Grotesk faces, headings, + links, admonitions, tables, radii, and the per-member `.thread-*` accent hook. + We do NOT re-declare any of that. + + IDENTITY: the shared "Loom" theme paints in dyed amber (#E9B04A) by default. + Filigree's federation thread is SKY (#56B7E2). This layer repaints the + interactive accent to sky so Filigree pages read in their own member colour — + the same recolour the shared theme's `.thread-filigree` hook performs, applied + site-wide here. On the light "Specimen" (warm-paper) scheme, raw sky and even + the shared theme's light highlight-sky (#1E7AB0 ≈ 3.7:1 on paper) fail WCAG AA + for link text, so we use a deepened sky (#16648F) verified ≥5:1 on both the + paper ground (5.06:1) and the raised card (5.74:1). + + Strategy (shared with the rest of the suite): Material owns the light/dark + toggle. We add NO custom toggle and use NO light-dark()/[data-theme]; the + --fg-* semantic tokens are redefined under Material's own scheme selectors + (which Material sets on ) so they resolve against the body-level scheme. + A token set on overrides one inherited from :root for every descendant, + so theme-varying tokens MUST live inside the scheme selectors, not in :root. ========================================================================== */ :root { - /* ---- Loom primitive ramp (OKLCH, perceptually uniform) — shared ---- */ - /* Slate/ink — shared Loom neutral hue (~255) */ - --loom-ink-50: oklch(0.97 0.010 255); - --loom-ink-100: oklch(0.93 0.018 255); - --loom-ink-200: oklch(0.86 0.028 255); - --loom-ink-700: oklch(0.40 0.060 255); - --loom-ink-900: oklch(0.22 0.040 255); - - /* Filigree accent — gold/amber (~82) */ - --fg-gold-200: oklch(0.90 0.060 82); - --fg-gold-300: oklch(0.84 0.090 82); - --fg-gold-400: oklch(0.77 0.115 82); - --fg-gold-500: oklch(0.68 0.120 78); - --fg-gold-600: oklch(0.58 0.105 72); - --fg-gold-700: oklch(0.50 0.090 68); - - /* ---- Spacing scale (4px base) — shared ---- */ + /* ---- Spacing scale (4px base) ---- */ --fg-space-1: 0.25rem; --fg-space-2: 0.5rem; --fg-space-3: 0.75rem; @@ -39,54 +35,91 @@ --fg-space-8: 3rem; --fg-space-10: 4rem; - /* ---- Fluid type — shared ---- */ + /* ---- Fluid type (landing hero) ---- */ --fg-text-hero: clamp(2.25rem, 1.6rem + 3vw, 3.5rem); --fg-text-lead: clamp(1.1rem, 1rem + 0.6vw, 1.375rem); - --fg-radius: 10px; + /* ---- Radii — the Loom 3 / 6 / 8 system ---- */ + --fg-radius-sm: 3px; /* chips, inline code, small surfaces */ + --fg-radius: 6px; /* buttons, cards */ + --fg-radius-lg: 8px; /* large panels */ + --fg-content-width: 52rem; + + /* Body face = JetBrains Mono (from weft-mkdocs); display = Space Grotesk for + the big landing headlines (matches the design system's brand-display rule). */ + --fg-font-mono: var(--md-code-font-family, 'JetBrains Mono', 'Fira Code', + ui-monospace, 'SF Mono', Menlo, monospace); + --fg-font-display: 'Space Grotesk', 'JetBrains Mono', ui-sans-serif, + system-ui, sans-serif; + + /* ---- Member thread colours (mirrors weft-mkdocs --thread-*; used by the + federation roster on the landing page so each member reads in its strand). */ + --fg-thread-loomweave: #52C9B8; + --fg-thread-filigree: #56B7E2; + --fg-thread-wardline: #F0875E; + --fg-thread-legis: #B79BF2; + --fg-thread-charter: #E9B04A; } -/* ---- Material variable overrides: LIGHT scheme ---- */ -[data-md-color-scheme="default"] { - /* Deep amber/bronze on white = AA contrast; used for links/headers */ - --md-primary-fg-color: var(--fg-gold-700); - --md-primary-fg-color--light: var(--fg-gold-500); - --md-primary-fg-color--dark: var(--fg-gold-700); - --md-accent-fg-color: var(--fg-gold-600); - - /* Semantic tokens for landing markup */ - --fg-bg: oklch(0.99 0.003 255); - --fg-bg-subtle: oklch(0.975 0.006 255); - --fg-bg-card: oklch(1 0 0); - --fg-text: var(--loom-ink-900); - --fg-text-muted: oklch(0.46 0.020 255); - --fg-border: var(--loom-ink-200); - --fg-accent: var(--fg-gold-700); - --fg-accent-soft: oklch(0.95 0.035 85); - --fg-on-accent: oklch(0.99 0.005 85); -} - -/* ---- Material variable overrides: DARK (slate) scheme ---- */ +/* ========================================================================== + Filigree semantic tokens — redefined per Material scheme, mapped onto the + shared "Loom" warm-espresso (dark) / warm-paper (light) surface ramp. The + accent is REPAINTED to the Filigree sky thread (the shared theme defaults to + amber); --fg-accent drives every interactive surface in this layer. + ========================================================================== */ + +/* ---- DARK (slate) scheme — the canonical default: warm espresso ground ---- */ [data-md-color-scheme="slate"] { - --md-primary-fg-color: var(--fg-gold-400); - --md-primary-fg-color--light: var(--fg-gold-300); - --md-primary-fg-color--dark: var(--fg-gold-500); - --md-accent-fg-color: var(--fg-gold-300); - - --fg-bg: oklch(0.21 0.022 255); - --fg-bg-subtle: oklch(0.25 0.022 255); - --fg-bg-card: oklch(0.27 0.024 255); - --fg-text: oklch(0.94 0.012 255); - --fg-text-muted: oklch(0.80 0.018 255); - --fg-border: oklch(0.36 0.024 255); - --fg-accent: var(--fg-gold-300); - --fg-accent-soft: oklch(0.32 0.045 78); - --fg-on-accent: oklch(0.18 0.030 78); + /* Loom dark surface ramp (design-system: base / raised / overlay / hover) */ + --fg-bg: #14110D; /* surface-base — warm espresso-black */ + --fg-bg-subtle: #1E1A13; /* surface-raised */ + --fg-bg-card: #2A2319; /* surface-overlay */ + --fg-bg-hover: #39301F; /* surface-hover */ + + /* Ivory text ramp */ + --fg-text: #F2E9D8; /* text-primary */ + --fg-text-muted: #B6A78E; /* text-secondary */ + + /* Borders — Loom hairlines */ + --fg-border: #332A1F; /* border-default */ + --fg-border-strong: #4A3C2A; /* border-strong */ + + /* Sky accent (the Filigree thread) — overrides the shared amber default. */ + --fg-accent: #56B7E2; /* sky */ + --fg-accent-soft: rgba(86, 183, 226, 0.14); + --fg-on-accent: #14110D; /* dark text on sky fill */ + + /* Repaint Material's own link/accent vars to sky for in-content links too. */ + --md-accent-fg-color: #56B7E2; + --md-typeset-a-color: #56B7E2; +} + +/* ---- LIGHT (default) scheme — "Specimen": warm-paper field-ledger ---- */ +[data-md-color-scheme="default"] { + /* Loom light surface ramp */ + --fg-bg: #ECE3D1; /* surface-base (paper) */ + --fg-bg-subtle: #E2D7BF; /* surface-overlay */ + --fg-bg-card: #F8F1E3; /* surface-raised (stock) */ + --fg-bg-hover: #D8CBB1; /* surface-hover */ + + --fg-text: #241E13; /* text-primary (ink) */ + --fg-text-muted: #5C5238; /* text-secondary */ + + --fg-border: #CDBE9F; /* border-default */ + --fg-border-strong: #B6A78E; /* border-strong */ + + /* Deepened sky — WCAG AA-safe (≥5:1) as link/accent on warm paper. */ + --fg-accent: #16648F; + --fg-accent-soft: rgba(22, 100, 143, 0.10); + --fg-on-accent: #F8F1E3; /* light text on sky fill (5.7:1) */ + + --md-accent-fg-color: #16648F; + --md-typeset-a-color: #16648F; } /* ========================================================================== - Landing page + LANDING PAGE ========================================================================== */ .fg-landing { @@ -101,11 +134,34 @@ padding: var(--fg-space-10) var(--fg-space-3) var(--fg-space-8); } +.fg-hero__eyebrow { + display: inline-flex; + align-items: center; + gap: var(--fg-space-2); + font-family: var(--fg-font-mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-text-muted); + margin-bottom: var(--fg-space-4); +} + +.fg-hero__eyebrow::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--fg-accent); +} + .fg-hero__title { + font-family: var(--fg-font-display); font-size: var(--fg-text-hero); line-height: 1.05; margin: 0 0 var(--fg-space-4); letter-spacing: -0.02em; + font-weight: 700; color: var(--fg-text); } @@ -113,7 +169,7 @@ font-size: var(--fg-text-lead); line-height: 1.5; color: var(--fg-text-muted); - max-width: 42rem; + max-width: 44rem; margin: 0 auto var(--fg-space-6); } @@ -138,33 +194,38 @@ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; } -.fg-btn--primary { +/* The `.fg-landing a.fg-btn…` ancestor+element specificity (0,2,1) is required + to beat Material's `.md-typeset a { color: var(--md-typeset-a-color) }` (0,1,1) + — without it the link colour paints the primary label sky-on-sky (invisible). */ +.fg-landing a.fg-btn--primary { background: var(--fg-accent); color: var(--fg-on-accent); } -.fg-btn--primary:hover, -.fg-btn--primary:focus-visible { - background: var(--fg-gold-600); +.fg-landing a.fg-btn--primary:hover, +.fg-landing a.fg-btn--primary:focus-visible { + /* Light scheme: darken the sky fill on hover. */ + background: color-mix(in oklch, var(--fg-accent) 82%, #000 18%); color: var(--fg-on-accent); } -[data-md-color-scheme="slate"] .fg-btn--primary:hover, -[data-md-color-scheme="slate"] .fg-btn--primary:focus-visible { - background: var(--fg-gold-200); +[data-md-color-scheme="slate"] .fg-landing a.fg-btn--primary:hover, +[data-md-color-scheme="slate"] .fg-landing a.fg-btn--primary:focus-visible { + /* Dark scheme: lighten the sky fill against the espresso base. */ + background: color-mix(in oklch, var(--fg-accent) 82%, #fff 18%); } -.fg-btn--secondary { +.fg-landing a.fg-btn--secondary { background: transparent; color: var(--fg-text); - border-color: var(--fg-border); + border-color: var(--fg-border-strong); } -.fg-btn--secondary:hover, -.fg-btn--secondary:focus-visible { +.fg-landing a.fg-btn--secondary:hover, +.fg-landing a.fg-btn--secondary:focus-visible { border-color: var(--fg-accent); color: var(--fg-accent); } .fg-btn:focus-visible { - outline: 3px solid var(--fg-text); + outline: 3px solid var(--fg-accent); outline-offset: 2px; } @@ -181,6 +242,10 @@ border: 1px solid var(--fg-border); border-radius: var(--fg-radius); background: var(--fg-bg-subtle); + transition: border-color 150ms ease; +} +.fg-feature-card:hover { + border-color: var(--fg-border-strong); } .fg-feature-card h3 { @@ -199,8 +264,9 @@ background: var(--fg-accent-soft); color: var(--fg-accent); padding: 0.1em 0.35em; - border-radius: 4px; + border-radius: var(--fg-radius-sm); font-size: 0.85em; + font-family: var(--fg-font-mono); } /* ---- Embedded markdown body (install + quick example) ---- */ @@ -216,34 +282,49 @@ margin-bottom: var(--fg-space-6); } -/* ---- Loom suite ---- */ -.fg-suite { +/* ========================================================================== + WEFT FEDERATION ROSTER (landing) + ========================================================================== */ +.fg-federation { margin-top: var(--fg-space-10); padding-top: var(--fg-space-6); border-top: 1px solid var(--fg-border); } -.fg-suite h2 { +.fg-federation h2 { + font-family: var(--fg-font-display); margin: 0 0 var(--fg-space-2); + font-weight: 600; } -.fg-suite__intro { +.fg-federation__intro { color: var(--fg-text-muted); - max-width: 44rem; + max-width: 46rem; line-height: 1.55; margin: 0 0 var(--fg-space-5); } -.fg-suite__grid { +.fg-federation__intro a { + color: var(--fg-accent); + text-decoration: none; +} +.fg-federation__intro a:hover { + text-decoration: underline; +} + +.fg-roster { display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: var(--fg-space-4); } -.fg-suite-card { +.fg-member { display: block; + position: relative; padding: var(--fg-space-5); + padding-left: calc(var(--fg-space-5) + 4px); border: 1px solid var(--fg-border); + border-left: 4px solid var(--fg-member-thread, var(--fg-border-strong)); border-radius: var(--fg-radius); background: var(--fg-bg-card); text-decoration: none; @@ -251,53 +332,147 @@ transition: border-color 150ms ease, transform 150ms ease; } -.fg-suite-card:hover, -.fg-suite-card:focus-visible { - border-color: var(--fg-accent); +.fg-member:hover, +.fg-member:focus-visible { + border-color: var(--fg-member-thread, var(--fg-accent)); + border-left-color: var(--fg-member-thread, var(--fg-accent)); transform: translateY(-2px); } -.fg-suite-card:focus-visible { - outline: 3px solid var(--fg-accent); +.fg-member:focus-visible { + outline: 3px solid var(--fg-member-thread, var(--fg-accent)); outline-offset: 2px; } -.fg-suite-card h3 { - margin: 0 0 var(--fg-space-2); - font-size: 1.1rem; - color: var(--fg-accent); +.fg-member__head { + display: flex; + align-items: baseline; + gap: var(--fg-space-2); + flex-wrap: wrap; } -.fg-suite-card p { +.fg-member__name { margin: 0; + font-family: var(--fg-font-mono); + font-size: 1.1rem; + color: var(--fg-member-thread, var(--fg-text)); +} + +.fg-member__lang { + font-family: var(--fg-font-mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fg-text-muted); +} + +.fg-member__domain { + margin: var(--fg-space-2) 0 0; color: var(--fg-text-muted); line-height: 1.5; + font-size: 0.95rem; } -.fg-suite-card--current { - border-color: var(--fg-accent); - background: var(--fg-accent-soft); +.fg-member__repo { + display: inline-block; + margin-top: var(--fg-space-3); + font-family: var(--fg-font-mono); + font-size: 0.78rem; + color: var(--fg-text-muted); + word-break: break-all; } -.fg-suite-card--current h3 { - color: var(--fg-text); +/* Per-member thread colour (drives left rule + name) */ +.fg-member--loomweave { --fg-member-thread: var(--fg-thread-loomweave); } +.fg-member--filigree { --fg-member-thread: var(--fg-thread-filigree); } +.fg-member--wardline { --fg-member-thread: var(--fg-thread-wardline); } +.fg-member--legis { --fg-member-thread: var(--fg-thread-legis); } +.fg-member--charter { --fg-member-thread: var(--fg-thread-charter); } + +/* You-are-here (Filigree) */ +.fg-member--current { + background: var(--fg-accent-soft); } -.fg-suite-card__badge { +.fg-member__badge { display: inline-block; - margin-top: var(--fg-space-3); - font-size: 0.72rem; + margin-left: auto; + align-self: center; + font-family: var(--fg-font-mono); + font-size: 0.66rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg-on-accent); - background: var(--fg-accent); - padding: 0.15em 0.6em; + background: var(--fg-thread-filigree); + padding: 0.12em 0.55em; border-radius: 999px; } +.fg-member__status { + display: inline-block; + margin-top: var(--fg-space-3); + margin-left: var(--fg-space-3); + font-family: var(--fg-font-mono); + font-size: 0.72rem; + color: var(--fg-text-muted); +} + +/* Charter = planned/scaffold — dim it */ +.fg-member--planned { + opacity: 0.72; +} + +/* Lacuna — adjacent, not a member */ +.fg-lacuna { + margin-top: var(--fg-space-5); + padding: var(--fg-space-4) var(--fg-space-5); + border: 1px dashed var(--fg-border-strong); + border-radius: var(--fg-radius); + background: transparent; + display: block; + text-decoration: none; + color: var(--fg-text); +} +.fg-lacuna:hover, +.fg-lacuna:focus-visible { + border-color: var(--fg-accent); +} +.fg-lacuna__label { + font-family: var(--fg-font-mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-text-muted); +} +.fg-lacuna__body { + margin: var(--fg-space-1) 0 0; + color: var(--fg-text-muted); + line-height: 1.5; + font-size: 0.9rem; +} +.fg-lacuna__body strong { + color: var(--fg-text); +} + +.fg-federation__more { + margin-top: var(--fg-space-5); + font-size: 0.95rem; + color: var(--fg-text-muted); +} +.fg-federation__more a { + color: var(--fg-accent); + text-decoration: none; + font-weight: 600; +} +.fg-federation__more a:hover { + text-decoration: underline; +} + /* ========================================================================== - Footer Loom suite links (copyright partial override) — site-wide + Footer Weft suite links (copyright partial override) — site-wide ========================================================================== */ .fg-footer-suite { display: flex; @@ -309,8 +484,12 @@ } .fg-footer-suite__label { + font-family: var(--fg-font-mono); font-weight: 700; - opacity: 0.8; + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.72rem; + opacity: 0.7; } .fg-footer-suite a { @@ -322,6 +501,7 @@ .fg-footer-suite a:hover, .fg-footer-suite a:focus-visible { text-decoration: underline; + text-decoration-color: var(--fg-accent); opacity: 1; } @@ -330,7 +510,9 @@ ========================================================================== */ @media (prefers-reduced-motion: reduce) { .fg-btn, - .fg-suite-card { + .fg-member, + .fg-lacuna, + .fg-feature-card { transition: none; } } diff --git a/docs/stylesheets/fonts/JetBrainsMono-Italic-Variable.ttf b/docs/stylesheets/fonts/JetBrainsMono-Italic-Variable.ttf new file mode 100644 index 00000000..5210f735 Binary files /dev/null and b/docs/stylesheets/fonts/JetBrainsMono-Italic-Variable.ttf differ diff --git a/docs/stylesheets/fonts/JetBrainsMono-OFL.txt b/docs/stylesheets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/docs/stylesheets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/stylesheets/fonts/JetBrainsMono-Variable.ttf b/docs/stylesheets/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 00000000..aa310be8 Binary files /dev/null and b/docs/stylesheets/fonts/JetBrainsMono-Variable.ttf differ diff --git a/docs/stylesheets/fonts/SpaceGrotesk-OFL.txt b/docs/stylesheets/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 00000000..cb512b9a --- /dev/null +++ b/docs/stylesheets/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/stylesheets/fonts/SpaceGrotesk-Variable.ttf b/docs/stylesheets/fonts/SpaceGrotesk-Variable.ttf new file mode 100644 index 00000000..a1b2e6c2 Binary files /dev/null and b/docs/stylesheets/fonts/SpaceGrotesk-Variable.ttf differ diff --git a/docs/stylesheets/weft-mkdocs.css b/docs/stylesheets/weft-mkdocs.css new file mode 100644 index 00000000..bc2a510f --- /dev/null +++ b/docs/stylesheets/weft-mkdocs.css @@ -0,0 +1,300 @@ +/* ============================================================================ + weft-mkdocs.css · v2 · 2026-06-07 + ---------------------------------------------------------------------------- + The Weft Federation shared docs theme for MkDocs Material. + + DERIVED FROM the design system — do not hand-edit the token VALUES below; + re-copy from `design-system/project/colors_and_type.css` on update and bump + the version stamp above. Per THEMING.md this is the *reference shared docs + theme* that filigree / wardline / legis / charter vendor in (replacing their + divergent `docs/stylesheets/extra.css`). Keep it portable: it assumes only + (a) these token values and (b) the two bundled fonts living in `./fonts/` + beside this file. + + MECHANISM (shared with the rest of the suite): + - Material owns the light/dark toggle. We add NO custom toggle and use NO + light-dark()/[data-theme]. We map the handoff tokens onto Material's own + scheme selectors: [data-md-color-scheme="slate"] = dark (CANONICAL), + [data-md-color-scheme="default"] = light. + - Token VALUES are lifted verbatim from the design system. This is the "Loom" + revision: the dark canonical scheme is a warm espresso ground with a dyed- + amber accent and the warmed per-member thread palette; the light scheme is + "Specimen" — a warm-paper field-ledger with oxblood-red ink. + - Fonts: JetBrains Mono = product/body face; Space Grotesk = brand/headings. + Bundled locally (OFL) so the site works fully offline; mkdocs.yml sets + `font: false` so Material pulls no Google Fonts. + + Contrast: the source tokens were verified >=4.5:1 in both themes by the + design-system handoff; this file inherits that guarantee (no re-check). + ============================================================================ */ + +/* --------------------------------------------------------------------------- + Bundled faces (paths relative to THIS file → ./fonts/) + --------------------------------------------------------------------------- */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('fonts/JetBrainsMono-Variable.ttf') format('truetype'); + font-weight: 100 800; font-style: normal; font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('fonts/JetBrainsMono-Italic-Variable.ttf') format('truetype'); + font-weight: 100 800; font-style: italic; font-display: swap; +} +@font-face { + font-family: 'Space Grotesk'; + src: url('fonts/SpaceGrotesk-Variable.ttf') format('truetype'); + font-weight: 300 700; font-style: normal; font-display: swap; +} + +/* --------------------------------------------------------------------------- + Token primitives — lifted verbatim from the design system. Theme-agnostic + (thread palette, radii, type families, motion). + --------------------------------------------------------------------------- */ +:root { + --weft-font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, monospace; + --weft-font-display: 'Space Grotesk', 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif; + + /* Radii (3 / 6 / 8) */ + --weft-radius-sm: 3px; + --weft-radius: 6px; + --weft-radius-lg: 8px; + + /* The Weft thread palette — one accent per member (warmed for "Loom"). */ + --thread-loomweave: #52C9B8; + --thread-filigree: #56B7E2; + --thread-wardline: #F0875E; + --thread-legis: #B79BF2; + --thread-charter: #E9B04A; + --thread-shuttle: #8C7C68; + --lacuna-accent: #C77FA6; + + /* Wire Material's own font slots to the Weft faces (font:false in mkdocs.yml + means Material emits --md-text/code-font-family but loads no webfont). */ + --md-text-font-family: var(--weft-font-mono); + --md-code-font-family: var(--weft-font-mono); +} + +/* =========================================================================== + DARK / slate scheme — the CANONICAL DEFAULT ("Loom") + Surfaces, text ramp, accent: design-system dark tokens. + =========================================================================== */ +[data-md-color-scheme="slate"] { + /* hue used by Material to derive its own slate ramp; keep it warm/amber */ + --md-hue: 35; + + /* Surfaces (design-system: base/raised/overlay/hover) */ + --md-default-bg-color: #14110D; /* surface-base */ + --md-default-bg-color--light: #1E1A13; /* surface-raised */ + --md-default-bg-color--lighter: #2A2319; /* surface-overlay */ + --md-default-bg-color--lightest:#39301F; /* surface-hover */ + + /* Text ramp (primary / secondary / muted) */ + --md-default-fg-color: #F2E9D8; /* text-primary */ + --md-default-fg-color--light: #B6A78E; /* text-secondary*/ + --md-default-fg-color--lighter: #7F6F58; /* text-muted */ + --md-default-fg-color--lightest:#332A1F; /* hairline tint (border-default) */ + + /* Primary chrome (header/nav) = raised surface, amber accent for marks/links */ + --md-primary-fg-color: #1E1A13; + --md-primary-fg-color--light: #2A2319; + --md-primary-fg-color--dark: #14110D; + --md-primary-bg-color: #F2E9D8; + --md-primary-bg-color--light: #B6A78E; + + /* Accent — dyed amber (the suite's interactive thread) */ + --md-accent-fg-color: #E9B04A; + --md-accent-fg-color--transparent: rgba(233, 176, 74, 0.10); + --md-accent-bg-color: #14110D; + --md-accent-bg-color--light: #1E1A13; + + /* Links resolve through the typeset link var below */ + --md-typeset-a-color: #E9B04A; + + /* Code surfaces */ + --md-code-bg-color: #1E1A13; + --md-code-fg-color: #F2E9D8; + --md-code-hl-color: rgba(233, 176, 74, 0.15); + + /* Syntax highlight (Pygments) — warm, terminal-grade */ + --md-code-hl-comment-color: #7F6F58; + --md-code-hl-keyword-color: #B79BF2; /* violet */ + --md-code-hl-string-color: #52C9B8; /* aqua */ + --md-code-hl-number-color: #E9B04A; /* gold */ + --md-code-hl-name-color: #F2E9D8; + --md-code-hl-function-color: #56B7E2; /* sky */ + --md-code-hl-constant-color: #F0875E; /* coral */ + --md-code-hl-operator-color: #B6A78E; + --md-code-hl-punctuation-color:#B6A78E; + --md-code-hl-variable-color: #F2E9D8; + --md-code-hl-special-color: #C77FA6; + + /* Selection / footer */ + --md-typeset-mark-color: rgba(233, 176, 74, 0.30); + --md-footer-bg-color: #14110D; + --md-footer-bg-color--dark: #0C0A07; + --md-footer-fg-color: #F2E9D8; + --md-footer-fg-color--light: #B6A78E; + --md-footer-fg-color--lighter:#7F6F58; +} + +/* =========================================================================== + LIGHT / default scheme — the design system's documented alternate ("Specimen") + Warm-paper field-ledger with oxblood-red ink. + =========================================================================== */ +[data-md-color-scheme="default"] { + --md-default-bg-color: #ECE3D1; /* surface-base (paper) */ + --md-default-bg-color--light: #F8F1E3; /* surface-raised (stock) */ + --md-default-bg-color--lighter: #E2D7BF; /* surface-overlay*/ + --md-default-bg-color--lightest:#D8CBB1; /* surface-hover */ + + --md-default-fg-color: #241E13; /* text-primary */ + --md-default-fg-color--light: #5C5238; /* text-secondary*/ + --md-default-fg-color--lighter: #897B5F; /* text-muted */ + --md-default-fg-color--lightest:#CDBE9F; /* hairline tint (border-default) */ + + --md-primary-fg-color: #F8F1E3; + --md-primary-fg-color--light: #E2D7BF; + --md-primary-fg-color--dark: #D8CBB1; + --md-primary-bg-color: #241E13; + --md-primary-bg-color--light: #5C5238; + + /* Accent — oxblood / madder (the ruling-red ink) */ + --md-accent-fg-color: #A33B2C; + --md-accent-fg-color--transparent: rgba(163, 59, 44, 0.10); + --md-accent-bg-color: #F8F1E3; + --md-accent-bg-color--light: #ECE3D1; + + --md-typeset-a-color: #A33B2C; + + --md-code-bg-color: #E2D7BF; + --md-code-fg-color: #241E13; + --md-code-hl-color: rgba(163, 59, 44, 0.12); + + /* Syntax highlight (Pygments) — embroidery-floss threads on paper */ + --md-code-hl-comment-color: #897B5F; + --md-code-hl-keyword-color: #6E4FC0; /* violet */ + --md-code-hl-string-color: #118C7E; /* aqua */ + --md-code-hl-number-color: #A9791F; /* gold */ + --md-code-hl-function-color: #1E7AB0; /* sky */ + --md-code-hl-constant-color: #CF5630; /* coral */ + --md-code-hl-operator-color: #5C5238; + --md-code-hl-punctuation-color:#5C5238; + + --md-typeset-mark-color: rgba(163, 59, 44, 0.18); + --md-footer-bg-color: #241E13; + --md-footer-bg-color--dark: #14110D; + --md-footer-fg-color: #F2E9D8; + --md-footer-fg-color--light: #B6A78E; + --md-footer-fg-color--lighter:#7F6F58; +} + +/* =========================================================================== + Typography — brand face for headings, mono for everything else. + Sizes nudged up from the dashboard's dense chrome for long-form reading. + =========================================================================== */ +.md-typeset { + font-family: var(--weft-font-mono); + font-feature-settings: "liga" 1, "calt" 1; + -webkit-font-smoothing: antialiased; +} +.md-typeset h1, +.md-typeset h2, +.md-typeset h3 { + font-family: var(--weft-font-display); + letter-spacing: -0.015em; + font-weight: 600; +} +.md-typeset h1 { font-weight: 700; letter-spacing: -0.02em; } + +/* Brand wordmark in the header uses the display face */ +.md-header__title, +.md-header__topic > .md-ellipsis { + font-family: var(--weft-font-display); + font-weight: 700; + letter-spacing: -0.02em; +} + +/* =========================================================================== + State-by-left-rule grammar (THEMING.md): admonitions/quotes carry a 4px + rule, not a fill. Tune Material's admonition accents to the semantic ramp. + =========================================================================== */ +.md-typeset .admonition, +.md-typeset details { + border-left-width: 4px; + border-radius: var(--weft-radius); + font-size: 0.72rem; +} +.md-typeset blockquote { + border-left: 4px solid var(--md-accent-fg-color); +} + +/* note/info → sky (status-wip, the cool pop) */ +.md-typeset .admonition.note, +.md-typeset .admonition.info { border-left-color: #56B7E2; } +.md-typeset .note > .admonition-title, +.md-typeset .info > .admonition-title { background-color: rgba(86,183,226,0.12); } + +/* tip/success → emerald (ready) */ +.md-typeset .admonition.tip, +.md-typeset .admonition.success { border-left-color: #5FB98E; } +.md-typeset .tip > .admonition-title, +.md-typeset .success > .admonition-title { background-color: rgba(95,185,142,0.12); } + +/* warning → amber (aging) */ +.md-typeset .admonition.warning { border-left-color: #E9B04A; } +.md-typeset .warning > .admonition-title { background-color: rgba(233,176,74,0.12); } + +/* danger/bug/failure → red (stale) */ +.md-typeset .admonition.danger, +.md-typeset .admonition.bug, +.md-typeset .admonition.failure { border-left-color: #E2604E; } +.md-typeset .danger > .admonition-title, +.md-typeset .bug > .admonition-title, +.md-typeset .failure > .admonition-title { background-color: rgba(226,96,78,0.12); } + +/* =========================================================================== + Radii — apply the 3/6/8 system to the surfaces Material rounds. + =========================================================================== */ +.md-typeset pre > code, +.md-typeset .highlight, +.md-typeset .tabbed-set, +.md-typeset table:not([class]) { + border-radius: var(--weft-radius); +} +.md-typeset code { border-radius: var(--weft-radius-sm); } +.md-typeset .md-button { border-radius: var(--weft-radius); } + +/* Tables — hairline grid, raised header (terminal-grade, dense). */ +.md-typeset table:not([class]) { + border: 1px solid var(--md-default-fg-color--lightest); +} +.md-typeset table:not([class]) th { + background-color: var(--md-default-bg-color--light); + font-weight: 600; +} + +/* Inline code — subtle accent tint, not a heavy fill. */ +.md-typeset code { + background-color: var(--md-code-bg-color); +} + +/* =========================================================================== + Member thread accents — opt-in per page via front-matter body class, e.g. + `body class: thread-wardline`. Recolors the link/accent to that member's + strand so a member page reads in its own identity colour. Portable: members + that vendor this file inherit the same hook. + =========================================================================== */ +.thread-loomweave { --md-accent-fg-color: var(--thread-loomweave); --md-typeset-a-color: var(--thread-loomweave); } +.thread-filigree { --md-accent-fg-color: var(--thread-filigree); --md-typeset-a-color: var(--thread-filigree); } +.thread-wardline { --md-accent-fg-color: var(--thread-wardline); --md-typeset-a-color: var(--thread-wardline); } +.thread-legis { --md-accent-fg-color: var(--thread-legis); --md-typeset-a-color: var(--thread-legis); } +.thread-charter { --md-accent-fg-color: var(--thread-charter); --md-typeset-a-color: var(--thread-charter); } +.thread-shuttle { --md-accent-fg-color: var(--thread-shuttle); --md-typeset-a-color: var(--thread-shuttle); } + +/* =========================================================================== + Reduced motion + =========================================================================== */ +@media (prefers-reduced-motion: reduce) { + .md-typeset * { transition: none !important; animation: none !important; } +} diff --git a/docs/superpowers/plans/2026-06-05-transport-bound-actor-identity.md b/docs/superpowers/plans/2026-06-05-transport-bound-actor-identity.md new file mode 100644 index 00000000..9a612668 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-transport-bound-actor-identity.md @@ -0,0 +1,1506 @@ +# Transport-bound Actor Identity (v24 slice) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bind each Filigree write's claimed `actor`/`author` to a transport-verified identity (the OS user the process runs as), recorded in a new nullable `verified_*` column on every runtime event-bearing table, surfaced on read, with a non-blocking mismatch warning. + +**Architecture:** Schema v24 adds a sibling `verified_*` column next to each claimed-actor column (no rename, no backfill, `NULL` = no transport proof). `FiligreeDB` carries a session-level `self._verified_actor` set once at the entry point (CLI `get_db()`, MCP-stdio `_init_db`); every runtime insert site stamps it; mismatch detection happens once per call at the entry point and never blocks. + +**Tech Stack:** Python 3.13, SQLite (via `sqlite3`), Click (CLI), MCP SDK (stdio server), mypy + ruff + pytest gate. + +**Branch:** `release/3.0.0` — commits land directly on the branch (this is a 3.0.0 breaking-bundle keystone change). Do NOT create or switch branches. + +**ADR:** extends ADR-012 (Actor Identity Threat Model). + +--- + +## Decisions locked before implementation (read these first) + +These resolve gaps and conflicts found between the spec and codebase reality. They are binding for this plan. + +1. **Migration naming.** The spec wrote `_migrate_v23_to_v24`; the actual codebase convention has **no leading underscore**: `migrate_v23_to_v24`, registered in `MIGRATIONS` under integer key `23`. `CURRENT_SCHEMA_VERSION` goes 23 → 24. + +2. **`db_base.py` is in scope (spec §9 omitted it).** Every insert site reads `self._verified_actor` through `DBMixinProtocol` (`db_base.py:268`). mypy will fail unless `_verified_actor: str | None` is declared on that protocol. This is Task 3. + +3. **`db_meta.py:add_comment` (line ~58) is a runtime stamp site (spec §7 omitted it).** Spec §7 named only `db_observations.py:840` for comments — the secondary observation-link path. `add_comment` is the *primary* user comment writer and MUST stamp `verified_author`. This is Task 5. + +4. **Import restores stored verified attribution (not NULL).** `export_jsonl` uses `SELECT *` (`db_meta.py:873-884`), so `verified_*` columns automatically flow into exports. The matching `import_jsonl` insert sites for **events, comments, file_events** therefore restore the stored value via `record.get("verified_*")` — mirroring the existing `migration_orphaned_at` preservation precedent (`db_meta.py:1376`). Setting them NULL would destroy verified attribution that existed at original write time (an audit-integrity regression). This is Task 7. The discriminator is *"am I recording a new event now, or restoring a recorded one?"* — restoring → preserve; new system write → NULL. + +5. **System/cascade writers stay NULL.** `finding_issue_cascade.py:record_reconciliation_debt_comment` (writes via a bare `conn`, system actor, no transport proof) and `migrations.py`/`migrate.py` leave the column NULL. No transport identity exists for these. + +6. **MCP mismatch surface — build the envelope `warnings` list now (user-confirmed).** Spec §6 says MCP "add it to the response envelope `warnings` list." That list does not exist today — every MCP tool handler returns `list[TextContent]` with its own JSON envelope. **Decision (confirmed with user): build it.** Rather than edit every handler, `call_tool` post-processes the handler's `list[TextContent]` result: parse the first text element's JSON, and if it is a dict, add/extend a top-level `warnings` array (a shared `_inject_warnings` helper). Bare-string and non-dict responses are left untouched. The same helper serves any future warning producer. This applies to both MCP-stdio and MCP-HTTP call paths (both go through `call_tool`); the *resolver* that sets `_verified_actor` is wired for stdio in this slice (HTTP peer identity remains the out-of-scope ticket, so HTTP requests simply carry `_verified_actor = None` → no mismatch warning until that ticket lands). + +7. **Tombstone synthetic records carry `verified_actor=None`.** `_deleted_issue_changes` (`db_events.py:274`) constructs `EventRecordWithTitle` by hand; once the TypedDict gains the field, this construction must add `verified_actor=None` (a hard-deleted issue has no verified actor) or mypy fails. This is Task 4. + +8. **`borrow_for_worker_thread` needs no new code.** It clones via `copy.copy(self)` (`core.py:1654`), which shallow-copies `self._verified_actor` for free. A propagation test is still written (Task 3) to lock the behavior against future refactors. + +9. **Mismatch warning suppresses placeholder-default claims (user-confirmed).** As specified (§6: "both non-empty and differ → warn"), the warning would fire on essentially *every* command: `--actor` defaults to `"cli"` (`cli.py:79`) while verified resolves to the OS user → mismatch every time; and intended agent usage (CLAUDE.md tells agents to pass e.g. `--actor clarion-bot`) also differs from the OS user every command — the whole premise of ADR-012. **Decision (confirmed with user): suppress placeholder defaults** — the warning fires only when the claim is a *real, distinct* identity, never for the framework's auto-default placeholders. Implemented in `actor_mismatch_warning(claimed, verified)` via a `_PLACEHOLDER_ACTORS` set (`{"cli", "mcp"}`): a claimed value in that set is treated as "no genuine claim" → returns `None`. **Recording both values in the DB (the real audit feature) is unaffected — every write still stamps `verified_*` regardless of the warning.** Only the warning surface is scoped. + +--- + +## File Structure + +**Create:** +- `src/filigree/actor_identity.py` — OS-user resolver (`resolve_os_actor`) + mismatch-warning builder (`actor_mismatch_warning`). One responsibility: transport identity resolution. No DB, no I/O beyond `pwd`/`os`. +- `tests/core/test_actor_identity.py` — unit tests for the resolver + warning builder. +- `tests/core/test_verified_actor.py` — stamping, read-path, propagation, import round-trip tests. + +**Modify:** +- `src/filigree/db_schema.py` — add `verified_*` to 5 `CREATE TABLE`s in `SCHEMA_SQL`; bump `CURRENT_SCHEMA_VERSION` 23→24. (`SCHEMA_V1_SQL` untouched — it is a frozen migration-test fixture.) +- `src/filigree/migrations.py` — add `migrate_v23_to_v24`; register key `23`. +- `src/filigree/db_base.py` — add `_verified_actor` to `DBMixinProtocol`. +- `src/filigree/core.py` — `FiligreeDB.__init__` param + `self._verified_actor` + `set_verified_actor`. +- `src/filigree/db_events.py` — stamp `_record_event`; project column in the 2 event-record builders + tombstone. +- `src/filigree/types/events.py` — `verified_actor` on `EventRecord`. +- `src/filigree/types/planning.py` — `verified_author` on `CommentRecord`. +- `src/filigree/types/core.py` — `verified_actor` on `ObservationDict`. +- `src/filigree/db_meta.py` — stamp `add_comment`; project `verified_author` in `get_comments`/`get_comment`; preserve on import (event/comment/file_event stages). +- `src/filigree/db_observations.py` — stamp `observe` INSERT + return dict; stamp observation-link comment. +- `src/filigree/db_files.py` — stamp 2 `file_events` inserts. +- `src/filigree/db_annotations.py` — stamp `_record_annotation_event`. +- `src/filigree/cli_common.py` — set verified actor in `get_db()`. +- `src/filigree/cli.py` — mismatch check in the `cli()` group callback. +- `src/filigree/mcp_server.py` — set verified actor in `_init_db`; mismatch log in `call_tool`. +- `tests/core/test_schema.py` — migration + fresh-schema + idempotent tests. +- `docs/SCHEMA_MIGRATIONS.md`, `CHANGELOG.md`, `docs/adr/ADR-012-*.md` (or wherever ADR-012 lives). + +--- + +## Pre-flight (run once before Task 1) + +- [ ] **Confirm starting state is green and on the right branch** + +Run: +```bash +cd /home/john/filigree +git branch --show-current # expect: release/3.0.0 +uv run pytest tests/core/test_schema.py -q +``` +Expected: branch is `release/3.0.0`; schema tests pass. If the branch differs, STOP and ask the user — do not switch branches. + +--- + +## Task 1: Schema v24 — migration + CREATE-TABLE columns + version bump + +**Files:** +- Modify: `src/filigree/db_schema.py` (5 `CREATE TABLE`s in `SCHEMA_SQL`; `CURRENT_SCHEMA_VERSION`) +- Modify: `src/filigree/migrations.py` (new `migrate_v23_to_v24`; register key `23`) +- Test: `tests/core/test_schema.py` + +- [ ] **Step 1: Write the failing migration test** + +Append to the migration test class in `tests/core/test_schema.py` (the class that contains `test_migration_v22_to_v23_adds_entity_kind_column`). Use the existing helpers `_make_db`, `_get_table_columns`, `_get_schema_version`, `SCHEMA_SQL`, `apply_pending_migrations`. + +```python + def test_migration_v23_to_v24_adds_verified_actor_columns(self, tmp_path: Path) -> None: + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + # Simulate a true v23 DB: drop the new columns and stamp the prior version. + conn.execute("ALTER TABLE events DROP COLUMN verified_actor") + conn.execute("ALTER TABLE file_events DROP COLUMN verified_actor") + conn.execute("ALTER TABLE annotation_events DROP COLUMN verified_actor") + conn.execute("ALTER TABLE comments DROP COLUMN verified_author") + conn.execute("ALTER TABLE observations DROP COLUMN verified_actor") + conn.execute("PRAGMA user_version = 23") + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES ('iss-1', 't', ?, ?)", + ("2026-05-01T00:00:00+00:00", "2026-05-01T00:00:00+00:00"), + ) + conn.execute( + "INSERT INTO events (issue_id, event_type, actor, created_at) VALUES ('iss-1', 'created', 'x', ?)", + ("2026-05-01T00:00:00+00:00",), + ) + conn.commit() + assert "verified_actor" not in _get_table_columns(conn, "events") + + applied = apply_pending_migrations(conn, 24) + + assert applied == 1 + assert _get_schema_version(conn) == 24 + assert "verified_actor" in _get_table_columns(conn, "events") + assert "verified_actor" in _get_table_columns(conn, "file_events") + assert "verified_actor" in _get_table_columns(conn, "annotation_events") + assert "verified_author" in _get_table_columns(conn, "comments") + assert "verified_actor" in _get_table_columns(conn, "observations") + # Pre-existing row reads NULL (no backfill). + row = conn.execute("SELECT verified_actor FROM events WHERE issue_id = 'iss-1'").fetchone() + assert row["verified_actor"] is None + conn.close() + + def test_migration_v23_to_v24_idempotent(self, tmp_path: Path) -> None: + from filigree.migrations import migrate_v23_to_v24 + + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + conn.commit() + # Columns already present (fresh schema) — re-running add_column is a no-op. + migrate_v23_to_v24(conn) + assert "verified_actor" in _get_table_columns(conn, "events") + assert "verified_author" in _get_table_columns(conn, "comments") + conn.close() + + def test_fresh_schema_has_v24_verified_columns(self, tmp_path: Path) -> None: + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + conn.commit() + assert "verified_actor" in _get_table_columns(conn, "events") + assert "verified_actor" in _get_table_columns(conn, "file_events") + assert "verified_actor" in _get_table_columns(conn, "annotation_events") + assert "verified_author" in _get_table_columns(conn, "comments") + assert "verified_actor" in _get_table_columns(conn, "observations") + conn.close() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/core/test_schema.py -k "v23_to_v24 or v24_verified" -v` +Expected: FAIL — `migrate_v23_to_v24` does not exist / `apply_pending_migrations(conn, 24)` raises "No migration registered for v23 → v24" / columns absent from `SCHEMA_SQL`. + +- [ ] **Step 3: Add the columns to `SCHEMA_SQL` (fresh DBs)** + +In `src/filigree/db_schema.py`, edit the 5 `CREATE TABLE` blocks inside `SCHEMA_SQL` (the first occurrence of each — lines ~50, ~75, ~228, ~243, ~370; NOT the `SCHEMA_V1_SQL` copies at lines ~505+). + +`events` — change the `event_seq` line to add a trailing column: +```sql + event_seq INTEGER NOT NULL DEFAULT 0, + verified_actor TEXT +); +``` + +`comments`: +```sql + text TEXT NOT NULL, + created_at TEXT NOT NULL, + verified_author TEXT +); +``` + +`file_events`: +```sql + actor TEXT DEFAULT '', + created_at TEXT NOT NULL, + verified_actor TEXT +); +``` + +`observations`: +```sql + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + verified_actor TEXT +); +``` + +`annotation_events`: +```sql + target_id TEXT DEFAULT '', + created_at TEXT NOT NULL, + verified_actor TEXT +); +``` + +- [ ] **Step 4: Bump the schema version** + +In `src/filigree/db_schema.py`: +```python +CURRENT_SCHEMA_VERSION = 24 +``` + +- [ ] **Step 5: Add the migration function and register it** + +In `src/filigree/migrations.py`, add after `migrate_v22_to_v23` (before the `MIGRATIONS` dict): +```python +def migrate_v23_to_v24(conn: sqlite3.Connection) -> None: + """v23 -> v24: Add nullable ``verified_*`` transport-bound actor columns (ADR-012). + + The ``actor``/``author`` string on a write is an unauthenticated *claim*. This + adds a sibling column on every runtime event-bearing table holding the + identity the transport *verified* (the OS user the writing process ran as), or + NULL when no transport proof exists. NULL is the default for all existing rows + (no backfill) and for every unverified or system-written row. The claimed + column is unchanged; the ``events`` dedup index is NOT extended — verified_actor + is attribution metadata, not part of event identity. Nullable (``default=None`` + adds no DEFAULT clause); existing rows read NULL. Idempotent: ``add_column`` + no-ops if the column already exists. + """ + add_column(conn, "events", "verified_actor", "TEXT", default=None) + add_column(conn, "file_events", "verified_actor", "TEXT", default=None) + add_column(conn, "annotation_events", "verified_actor", "TEXT", default=None) + add_column(conn, "comments", "verified_author", "TEXT", default=None) + add_column(conn, "observations", "verified_actor", "TEXT", default=None) +``` + +Then add to the `MIGRATIONS` dict (after the `22: migrate_v22_to_v23,` line): +```python + 23: migrate_v23_to_v24, +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `uv run pytest tests/core/test_schema.py -v` +Expected: PASS (all migration tests, including the three new ones). + +- [ ] **Step 7: Commit** + +```bash +git add src/filigree/db_schema.py src/filigree/migrations.py tests/core/test_schema.py +git commit -m "feat(schema): v24 — add nullable verified_* actor columns + +Adds verified_actor/verified_author to events, file_events, +annotation_events, comments, observations. Nullable, no backfill; +events dedup index unchanged. ADR-012. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `actor_identity.py` — OS-user resolver + mismatch-warning builder + +**Files:** +- Create: `src/filigree/actor_identity.py` +- Test: `tests/core/test_actor_identity.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/core/test_actor_identity.py`: +```python +"""Tests for transport-bound actor identity resolution (ADR-012, schema v24).""" + +from __future__ import annotations + +import builtins + +from filigree.actor_identity import actor_mismatch_warning, resolve_os_actor + + +def test_resolve_os_actor_returns_str_on_posix() -> None: + # On the POSIX CI/dev host this resolves to the running user's name. + result = resolve_os_actor() + assert result is None or isinstance(result, str) + assert result != "" # never an empty string — None or a real name + + +def test_resolve_os_actor_returns_none_when_pwd_unavailable(monkeypatch) -> None: + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "pwd": + raise ModuleNotFoundError("No module named 'pwd'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + assert resolve_os_actor() is None # does not raise + + +def test_mismatch_warning_none_when_equal() -> None: + assert actor_mismatch_warning("alice", "alice") is None + + +def test_mismatch_warning_none_when_either_empty() -> None: + assert actor_mismatch_warning("alice", None) is None + assert actor_mismatch_warning("alice", "") is None + assert actor_mismatch_warning(None, "alice") is None + assert actor_mismatch_warning("", "alice") is None + + +def test_mismatch_warning_emitted_when_both_present_and_differ() -> None: + warning = actor_mismatch_warning("agent-x", "alice") + assert warning == {"code": "ACTOR_MISMATCH", "claimed": "agent-x", "verified": "alice"} + + +def test_mismatch_warning_suppressed_for_placeholder_default_claims() -> None: + # Framework auto-defaults are not genuine claims — no warning even though + # "cli"/"mcp" differ from the verified OS user. + assert actor_mismatch_warning("cli", "alice") is None + assert actor_mismatch_warning("mcp", "alice") is None +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_actor_identity.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'filigree.actor_identity'`. + +- [ ] **Step 3: Create the module** + +Create `src/filigree/actor_identity.py`: +```python +"""Transport-bound actor identity resolution (ADR-012, schema v24). + +The ``actor`` string on a Filigree write is an unauthenticated *claim*, not a +proof. This module resolves a best-effort *verified* identity from the +transport (the OS user the process runs as) and builds the structured warning +emitted when the claimed and verified identities disagree. Resolution never +raises and never blocks a write: a missing or unresolvable identity yields +``None`` and the write proceeds with ``verified_actor = NULL``. +""" + +from __future__ import annotations + +from typing import TypedDict + + +def resolve_os_actor() -> str | None: + """Best-effort OS-user identity, or ``None`` on any failure. + + Uses ``pwd.getpwuid(os.geteuid())`` on POSIX. Windows has no ``pwd`` + module, so the import fails and we return ``None`` (verified_actor stays + NULL — no crash, per the cross-platform contract). + """ + try: + import os + import pwd + + return pwd.getpwuid(os.geteuid()).pw_name or None + except Exception: + return None + + +class ActorMismatchWarning(TypedDict): + """Structured warning emitted when claimed actor != verified actor.""" + + code: str + claimed: str + verified: str + + +# Framework auto-default actor strings. A claim equal to one of these is NOT a +# genuine identity assertion (it is what Click/MCP fill in when the caller +# supplied nothing), so a difference from the verified OS user is expected and +# must not produce a warning. The DB still records the value verbatim; only the +# warning surface is suppressed. (ADR-012 decision 9.) +_PLACEHOLDER_ACTORS = frozenset({"cli", "mcp"}) + + +def actor_mismatch_warning(claimed: str | None, verified: str | None) -> ActorMismatchWarning | None: + """Return a structured warning when claimed and verified identities differ. + + Returns ``None`` (no warning) unless BOTH values are non-empty, differ, and + the claim is a *genuine* identity (not a framework placeholder default). A + missing/empty/placeholder claimed value, or an empty verified value, is an + unverified surface rather than a conflict. Never raises, never blocks a write. + """ + if claimed and verified and claimed != verified and claimed not in _PLACEHOLDER_ACTORS: + return {"code": "ACTOR_MISMATCH", "claimed": claimed, "verified": verified} + return None +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/core/test_actor_identity.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/filigree/actor_identity.py tests/core/test_actor_identity.py +git commit -m "feat: add actor_identity resolver + mismatch-warning builder (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Session-level identity plumbing (constructor + setter + protocol + propagation) + +**Files:** +- Modify: `src/filigree/core.py` (`FiligreeDB.__init__`, new `set_verified_actor`) +- Modify: `src/filigree/db_base.py` (`DBMixinProtocol`) +- Test: `tests/core/test_verified_actor.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/core/test_verified_actor.py`. (Use the existing fresh-DB construction helper. The conventional way to stand up a `FiligreeDB` in this suite is `FiligreeDB.from_filigree_dir` after `filigree init`; if a project test fixture like `tmp_db` exists in `tests/conftest.py` or `tests/core/conftest.py`, prefer it. The code below uses a minimal direct construction against a temp DB path — adjust the constructor call to match the suite's existing helper if one is present.) + +```python +"""Tests for transport-bound verified-actor plumbing (ADR-012, schema v24).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from filigree.core import FiligreeDB + + +@pytest.fixture +def db(tmp_path: Path) -> FiligreeDB: + filigree_dir = tmp_path / ".filigree" + filigree_dir.mkdir() + database = FiligreeDB.from_filigree_dir(filigree_dir) + return database + + +def test_constructor_defaults_verified_actor_to_none(db: FiligreeDB) -> None: + assert db._verified_actor is None + + +def test_set_verified_actor_updates_field(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + assert db._verified_actor == "alice" + db.set_verified_actor(None) + assert db._verified_actor is None + + +def test_borrow_for_worker_thread_propagates_verified_actor(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + with db.borrow_for_worker_thread() as clone: + assert clone._verified_actor == "alice" +``` + +> If `FiligreeDB.from_filigree_dir` requires additional setup (templates, registry), copy the construction idiom from an existing core test such as `tests/core/test_crud.py`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_verified_actor.py -v` +Expected: FAIL — `AttributeError: 'FiligreeDB' object has no attribute '_verified_actor'`. + +- [ ] **Step 3: Add the field + setter to `FiligreeDB`** + +In `src/filigree/core.py`, add a parameter to `__init__` (the signature at line ~981). Add after `skip_clarion_capability_probe: bool = False,`: +```python + skip_clarion_capability_probe: bool = False, + verified_actor: str | None = None, +``` + +Then in the `__init__` body, alongside the other `self.*` assignments (e.g. just after `self._check_same_thread = check_same_thread` at line ~1016), add: +```python + # ADR-012 (schema v24): the transport-verified identity for this session, + # set once at the entry point (CLI get_db / MCP _init_db). None = no + # transport proof; every runtime insert stamps this into verified_*. + # ``borrow_for_worker_thread`` propagates it for free via copy.copy. + self._verified_actor: str | None = verified_actor +``` + +Add the setter method to the `FiligreeDB` class (place it near `borrow_for_worker_thread`, e.g. just before it at line ~1614): +```python + def set_verified_actor(self, value: str | None) -> None: + """Set the transport-verified identity for this session. + + Entry points (CLI ``get_db``, MCP ``_init_db``) construct the DB before + resolving identity, then call this. Every subsequent runtime write + stamps ``value`` into its ``verified_*`` column. ``None`` (the default) + leaves writes unverified (``verified_* = NULL``). + """ + self._verified_actor = value +``` + +- [ ] **Step 4: Declare the attribute on `DBMixinProtocol`** + +In `src/filigree/db_base.py`, in the `DBMixinProtocol` shared-attributes block (after `_conn: sqlite3.Connection | None` at line ~293), add: +```python + # ADR-012 (schema v24): transport-verified session identity. Mixins read + # this directly when stamping verified_* columns. Set on FiligreeDB.__init__ + # (defaults to None); never None-guarded at insert sites. + _verified_actor: str | None +``` + +- [ ] **Step 5: Run the test + mypy to verify** + +Run: `uv run pytest tests/core/test_verified_actor.py -v && uv run mypy src/filigree/core.py src/filigree/db_base.py` +Expected: PASS; mypy clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/filigree/core.py src/filigree/db_base.py tests/core/test_verified_actor.py +git commit -m "feat: session-level verified_actor plumbing on FiligreeDB (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Stamp `events` + project `verified_actor` on the read path + +**Files:** +- Modify: `src/filigree/db_events.py` (`_record_event`, `_build_event_record`, `_build_event_record_with_title`, `_deleted_issue_changes`) +- Modify: `src/filigree/types/events.py` (`EventRecord`) +- Test: `tests/core/test_verified_actor.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/core/test_verified_actor.py`: +```python +def _create_issue(db: FiligreeDB) -> str: + issue = db.create_issue(title="t", actor="agent-x") + return issue.id + + +def test_event_stamps_verified_actor_when_set(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + issue_id = _create_issue(db) + row = db.conn.execute( + "SELECT verified_actor FROM events WHERE issue_id = ? AND event_type = 'created'", + (issue_id,), + ).fetchone() + assert row["verified_actor"] == "alice" + + +def test_event_verified_actor_null_when_unset(db: FiligreeDB) -> None: + # No set_verified_actor call — unverified surface. + issue_id = _create_issue(db) + row = db.conn.execute( + "SELECT verified_actor FROM events WHERE issue_id = ? AND event_type = 'created'", + (issue_id,), + ).fetchone() + assert row["verified_actor"] is None + + +def test_event_record_read_path_exposes_verified_actor(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + issue_id = _create_issue(db) + events = db.get_issue_events(issue_id) + created = next(e for e in events if e["event_type"] == "created") + assert created["verified_actor"] == "alice" +``` + +> Adjust `db.create_issue(...)` to the actual create signature if it differs (check `tests/core/test_crud.py`). The point is: any write that records an event. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "event" -v` +Expected: FAIL — `verified_actor` not in the INSERT (stored NULL even when set) / `KeyError: 'verified_actor'` from the read builder. + +- [ ] **Step 3: Stamp the INSERT in `_record_event`** + +In `src/filigree/db_events.py`, replace the `_record_event` INSERT (lines ~98-102): +```python + self.conn.execute( + "INSERT INTO events (issue_id, event_type, actor, verified_actor, old_value, new_value, comment, created_at, event_seq) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(event_seq) FROM events WHERE issue_id = ?), -1) + 1)", + (issue_id, event_type, actor, self._verified_actor, old_value, new_value, comment, _now_iso(), issue_id), + ) +``` + +- [ ] **Step 4: Project the column in both record builders** + +In `_build_event_record` (line ~44), add the field to the returned `EventRecord`: +```python + return EventRecord( + id=row["id"], + issue_id=row["issue_id"], + event_type=row["event_type"], + actor=row["actor"], + verified_actor=row["verified_actor"], + old_value=row["old_value"], + new_value=row["new_value"], + comment=row["comment"], + created_at=row["created_at"], + ) +``` + +In `_build_event_record_with_title` (line ~58), add the same field: +```python + return EventRecordWithTitle( + id=row["id"], + issue_id=row["issue_id"], + event_type=row["event_type"], + actor=row["actor"], + verified_actor=row["verified_actor"], + old_value=row["old_value"], + new_value=row["new_value"], + comment=row["comment"], + created_at=row["created_at"], + issue_title=row["issue_title"], + ) +``` + +- [ ] **Step 5: Add `verified_actor=None` to the tombstone synthetic record** + +In `_deleted_issue_changes` (the `EventRecordWithTitle(...)` construction at line ~275), add the field (a hard-deleted issue has no verified actor): +```python + records.append( + EventRecordWithTitle( + id=synthetic_id, + issue_id=r["issue_id"], + event_type="issue_deleted", + actor=r["deleted_by"] or "", + verified_actor=None, + old_value=None, + new_value=None, + comment="", + created_at=r["deleted_at"], + issue_title=r["title"] or "", + affected_entities=affected_entities, + ) + ) +``` + +- [ ] **Step 6: Add the field to the `EventRecord` TypedDict** + +In `src/filigree/types/events.py`, add to `EventRecord` (line ~104, after `actor: str`): +```python + id: int + issue_id: str + event_type: EventType + actor: str + verified_actor: str | None + old_value: str | None + new_value: str | None + comment: str + created_at: ISOTimestamp +``` +(`EventRecordWithTitle` inherits `EventRecord`, so it gets the field automatically.) + +- [ ] **Step 7: Run the tests + mypy to verify they pass** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "event" -v && uv run mypy src/filigree/db_events.py src/filigree/types/events.py` +Expected: PASS; mypy clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/filigree/db_events.py src/filigree/types/events.py tests/core/test_verified_actor.py +git commit -m "feat: stamp verified_actor on events + expose on read path (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Stamp `comments` (primary + observation-link) + project `verified_author` + +**Files:** +- Modify: `src/filigree/db_meta.py` (`add_comment` INSERT; `get_comments`/`get_comment` projection) +- Modify: `src/filigree/db_observations.py` (observation-link comment INSERT, line ~840) +- Modify: `src/filigree/types/planning.py` (`CommentRecord`) +- Test: `tests/core/test_verified_actor.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/core/test_verified_actor.py`: +```python +def test_add_comment_stamps_verified_author(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + issue_id = _create_issue(db) + db.add_comment(issue_id, "hello", author="agent-x") + row = db.conn.execute( + "SELECT verified_author FROM comments WHERE issue_id = ?", (issue_id,) + ).fetchone() + assert row["verified_author"] == "alice" + + +def test_get_comments_exposes_verified_author(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + issue_id = _create_issue(db) + db.add_comment(issue_id, "hello", author="agent-x") + comments = db.get_comments(issue_id) + assert comments[0]["verified_author"] == "alice" + + +def test_comment_verified_author_null_when_unset(db: FiligreeDB) -> None: + issue_id = _create_issue(db) + db.add_comment(issue_id, "hello", author="agent-x") + comments = db.get_comments(issue_id) + assert comments[0]["verified_author"] is None +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "comment or author" -v` +Expected: FAIL — `verified_author` not stamped / `KeyError: 'verified_author'`. + +- [ ] **Step 3: Stamp `add_comment`** + +In `src/filigree/db_meta.py`, replace the `add_comment` INSERT (line ~58-61): +```python + cursor = self.conn.execute( + "INSERT INTO comments (issue_id, author, verified_author, text, created_at) VALUES (?, ?, ?, ?, ?)", + (issue_id, author, self._verified_actor, text, now), + ) +``` + +- [ ] **Step 4: Project `verified_author` in `get_comments` and `get_comment`** + +In `src/filigree/db_meta.py`, replace `get_comments` (line ~68-73): +```python + def get_comments(self, issue_id: str) -> list[CommentRecord]: + rows = self.conn.execute( + "SELECT id, author, verified_author, text, created_at FROM comments WHERE issue_id = ? ORDER BY created_at", + (issue_id,), + ).fetchall() + return [ + CommentRecord( + id=r["id"], + author=r["author"], + verified_author=r["verified_author"], + text=r["text"], + created_at=r["created_at"], + ) + for r in rows + ] +``` + +Replace `get_comment` (line ~75-83): +```python + def get_comment(self, comment_id: int) -> CommentRecord: + row = self.conn.execute( + "SELECT id, author, verified_author, text, created_at FROM comments WHERE id = ?", + (comment_id,), + ).fetchone() + if row is None: + msg = f"Comment not found: {comment_id}" + raise KeyError(msg) + return CommentRecord( + id=row["id"], + author=row["author"], + verified_author=row["verified_author"], + text=row["text"], + created_at=row["created_at"], + ) +``` + +- [ ] **Step 5: Stamp the observation-link comment** + +In `src/filigree/db_observations.py`, replace the comment INSERT (line ~839-842): +```python + self.conn.execute( + "INSERT INTO comments (issue_id, author, verified_author, text, created_at) VALUES (?, ?, ?, ?, ?)", + (issue_id, actor, self._verified_actor, comment, now), + ) +``` + +- [ ] **Step 6: Add the field to `CommentRecord`** + +In `src/filigree/types/planning.py`, add to `CommentRecord` (line ~139, after `author: str`): +```python +class CommentRecord(TypedDict): + """Row from the comments table returned by ``get_comments()``.""" + + id: int + author: str + verified_author: str | None + text: str + created_at: ISOTimestamp +``` + +- [ ] **Step 7: Run the tests + mypy to verify they pass** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "comment or author" -v && uv run mypy src/filigree/db_meta.py src/filigree/db_observations.py src/filigree/types/planning.py` +Expected: PASS; mypy clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/filigree/db_meta.py src/filigree/db_observations.py src/filigree/types/planning.py tests/core/test_verified_actor.py +git commit -m "feat: stamp verified_author on comments + expose on read (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Stamp `file_events`, `annotation_events`, `observations` + +**Files:** +- Modify: `src/filigree/db_files.py` (`_record_registry_fallback_event` line ~286; file-metadata-update insert line ~566) +- Modify: `src/filigree/db_annotations.py` (`_record_annotation_event` line ~562) +- Modify: `src/filigree/db_observations.py` (`observe` INSERT line ~368 + return dict line ~439) +- Modify: `src/filigree/types/core.py` (`ObservationDict`) +- Test: `tests/core/test_verified_actor.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/core/test_verified_actor.py`: +```python +def test_observation_stamps_and_exposes_verified_actor(db: FiligreeDB) -> None: + db.set_verified_actor("alice") + obs = db.observe(summary="smell in foo.py", actor="agent-x") + assert obs["verified_actor"] == "alice" + # Read-back via list also carries it. + listed = db.list_observations() + assert listed[0]["verified_actor"] == "alice" + + +def test_observation_verified_actor_null_when_unset(db: FiligreeDB) -> None: + obs = db.observe(summary="another smell", actor="agent-x") + assert obs["verified_actor"] is None +``` + +> Adjust `db.observe(...)` to the real signature (check `tests/core/test_observations.py`). `file_events` and `annotation_events` stamping is covered structurally below; add a direct column-read assertion for those if the suite has an easy file-update / annotation-event entry point (e.g. `db.update_file(...)`, `db.create_annotation(...)` then an annotation mutation). If a convenient entry point is not obvious, assert at minimum that the stamped column exists and is NULL/value via a raw `db.conn.execute("SELECT verified_actor FROM file_events ...")` after the relevant operation. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "observation" -v` +Expected: FAIL — `KeyError: 'verified_actor'` on the returned dict / column stored NULL when set. + +- [ ] **Step 3: Stamp `file_events` — registry-fallback event** + +In `src/filigree/db_files.py`, replace `_record_registry_fallback_event`'s INSERT (line ~285-289): +```python + self.conn.execute( + "INSERT INTO file_events " + "(file_id, event_type, field, old_value, new_value, actor, verified_actor, created_at) " + "VALUES (?, 'registry_local_fallback', 'registry_backend', 'clarion', 'local', ?, ?, ?)", + (file_id, actor, self._verified_actor, now), + ) +``` + +- [ ] **Step 4: Stamp `file_events` — file-metadata-update event** + +In `src/filigree/db_files.py`, replace the metadata-update INSERT (line ~565-570): +```python + self.conn.execute( + "INSERT INTO file_events " + "(file_id, event_type, field, old_value, new_value, actor, verified_actor, created_at) " + "VALUES (?, 'file_metadata_update', ?, ?, ?, ?, ?, ?)", + (existing["id"], field, old_val, new_val, actor, self._verified_actor, now), + ) +``` + +- [ ] **Step 5: Stamp `annotation_events`** + +In `src/filigree/db_annotations.py`, replace the `_record_annotation_event` INSERT (line ~561-566): +```python + self.conn.execute( + "INSERT INTO annotation_events " + "(id, annotation_id, event_type, actor, verified_actor, reason, old_value, new_value, target_type, target_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (event_id, annotation_id, event_type, actor, self._verified_actor, reason, old_text, new_text, target_type, target_id, now), + ) +``` + +- [ ] **Step 6: Stamp `observations` INSERT** + +In `src/filigree/db_observations.py`, replace the `observe` INSERT (lines ~368-385). Add `verified_actor` to the column list (after `actor`) and `self._verified_actor` to the values tuple (after `actor`): +```python + self.conn.execute( + "INSERT INTO observations (id, summary, detail, file_id, file_path, line, " + "source_issue_id, source_finding_id, priority, actor, verified_actor, created_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + obs_id, + summary_stripped, + detail, + linked_file_id, + file_path, + line, + source_issue_id, + source_finding_id, + priority, + actor, + self._verified_actor, + now, + expires, + ), + ) +``` + +- [ ] **Step 7: Stamp the `observe` return dict** + +In `src/filigree/db_observations.py`, the hand-built return dict (line ~439-452) must include the field so the value returned to the caller matches what was stored: +```python + return { + "id": obs_id, + "summary": summary_stripped, + "detail": detail, + "file_id": linked_file_id, + "file_path": file_path, + "line": line, + "source_issue_id": source_issue_id, + "source_finding_id": source_finding_id, + "priority": priority, + "actor": actor, + "verified_actor": self._verified_actor, + "created_at": now, + "expires_at": ISOTimestamp(expires), + } +``` + +> The `list_observations` / `get_observations_by_ids` / dedup-winner read paths use `cast(ObservationDict, dict(row))` over `SELECT *`, so they pick up `verified_actor` automatically once the column exists and the TypedDict declares it — no query change needed there. + +- [ ] **Step 8: Add the field to `ObservationDict`** + +In `src/filigree/types/core.py`, add to `ObservationDict` (line ~221, after `actor: str`): +```python + priority: int + actor: str + verified_actor: str | None + created_at: ISOTimestamp + expires_at: ISOTimestamp +``` + +- [ ] **Step 9: Run the tests + mypy to verify they pass** + +Run: `uv run pytest tests/core/test_verified_actor.py -v && uv run mypy src/filigree/db_files.py src/filigree/db_annotations.py src/filigree/db_observations.py src/filigree/types/core.py` +Expected: PASS; mypy clean. + +- [ ] **Step 10: Commit** + +```bash +git add src/filigree/db_files.py src/filigree/db_annotations.py src/filigree/db_observations.py src/filigree/types/core.py tests/core/test_verified_actor.py +git commit -m "feat: stamp verified_actor on file_events, annotation_events, observations (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Import round-trip preservation + document system-NULL writers + +**Files:** +- Modify: `src/filigree/db_meta.py` (`import_jsonl`: event stage line ~1330; comment stages lines ~1296 merge / ~1314 non-merge; file_event stages lines ~1387 merge / ~1410 non-merge) +- Modify: `src/filigree/finding_issue_cascade.py` (comment to document NULL is intentional — no code change) +- Test: `tests/core/test_verified_actor.py` + +**Rationale (decision 4/5):** export uses `SELECT *`, so a backup carries `verified_*`. Import is *restoring a recorded audit trail*, so it must preserve the stored value (`record.get("verified_*")`), exactly as `migration_orphaned_at` is preserved at `db_meta.py:1376`. System/cascade writes (reconciliation debt, migrations) create *new* records with no transport proof and stay NULL. + +- [ ] **Step 1: Write the failing test (export → import round-trip)** + +Append to `tests/core/test_verified_actor.py`: +```python +def test_export_import_round_trips_verified_actor(tmp_path: Path) -> None: + src_dir = tmp_path / "src" / ".filigree" + src_dir.mkdir(parents=True) + src = FiligreeDB.from_filigree_dir(src_dir) + src.set_verified_actor("alice") + issue = src.create_issue(title="t", actor="agent-x") + src.add_comment(issue.id, "hello", author="agent-x") + export_path = tmp_path / "dump.jsonl" + src.export_jsonl(export_path) + + dst_dir = tmp_path / "dst" / ".filigree" + dst_dir.mkdir(parents=True) + dst = FiligreeDB.from_filigree_dir(dst_dir) + # Import must NOT stamp the importer's identity; it restores the recorded one. + dst.set_verified_actor("bob") + dst.import_jsonl(export_path) + + ev = dst.conn.execute( + "SELECT verified_actor FROM events WHERE issue_id = ? AND event_type = 'created'", + (issue.id,), + ).fetchone() + assert ev["verified_actor"] == "alice" + cm = dst.conn.execute( + "SELECT verified_author FROM comments WHERE issue_id = ?", (issue.id,) + ).fetchone() + assert cm["verified_author"] == "alice" +``` + +> Adjust `import_jsonl` / `export_jsonl` call shapes to the real API (check `tests/cli/test_admin_commands.py` for the canonical round-trip idiom; the CLI verbs are `admin export-jsonl` / `admin import-jsonl`). If a v23 export fixture without `verified_*` keys is asserted elsewhere, the `record.get(...)` default of `None` keeps it loading cleanly — add an assertion that a record lacking the key imports as NULL. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "round_trip" -v` +Expected: FAIL — imported rows read NULL because the import INSERTs omit `verified_*`. + +- [ ] **Step 3: Preserve `verified_actor` on event import** + +In `src/filigree/db_meta.py`, the `event` import stage (line ~1330-1344), add the column + `record.get`: +```python + cursor = self.conn.execute( + f"INSERT {conflict} INTO events " + "(issue_id, event_type, actor, verified_actor, old_value, new_value, comment, created_at, event_seq) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record.get("issue_id", ""), + record.get("event_type", ""), + record.get("actor", ""), + record.get("verified_actor"), + record.get("old_value"), + record.get("new_value"), + record.get("comment", ""), + _normalize_iso_to_utc(record.get("created_at")) or _now_iso(), + int(record.get("event_seq", 0)), + ), + ) +``` + +- [ ] **Step 4: Preserve `verified_author` on comment import (both branches)** + +In `src/filigree/db_meta.py`, the comment merge branch (line ~1296-1310): +```python + cursor = self.conn.execute( + "INSERT INTO comments (issue_id, author, verified_author, text, created_at) " + "SELECT ?, ?, ?, ?, ? " + "WHERE NOT EXISTS (" + " SELECT 1 FROM comments WHERE issue_id = ? AND author = ? AND text = ? AND created_at = ?" + ")", + ( + record.get("issue_id", ""), + record.get("author", ""), + record.get("verified_author"), + record.get("text", ""), + created, + record.get("issue_id", ""), + record.get("author", ""), + record.get("text", ""), + created, + ), + ) +``` + +The comment non-merge branch (line ~1314-1322): +```python + cursor = self.conn.execute( + "INSERT INTO comments (issue_id, author, verified_author, text, created_at) VALUES (?, ?, ?, ?, ?)", + ( + record.get("issue_id", ""), + record.get("author", ""), + record.get("verified_author"), + record.get("text", ""), + _normalize_iso_to_utc(record.get("created_at")) or _now_iso(), + ), + ) +``` + +- [ ] **Step 5: Preserve `verified_actor` on file_event import (both branches)** + +In `src/filigree/db_meta.py`, the file_event merge branch (line ~1387-1406): +```python + cursor = self.conn.execute( + "INSERT INTO file_events (file_id, event_type, field, old_value, new_value, verified_actor, created_at) " + "SELECT ?, ?, ?, ?, ?, ?, ? " + "WHERE NOT EXISTS (" + " SELECT 1 FROM file_events " + " WHERE file_id = ? AND event_type = ? AND field = ? AND old_value = ? AND new_value = ? AND created_at = ?" + ")", + ( + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + record.get("verified_actor"), + created, + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + created, + ), + ) +``` + +The file_event non-merge branch (line ~1410 onward — the `INSERT INTO file_events ... VALUES (?, ?, ?, ?, ?, ?)`): +```python + cursor = self.conn.execute( + "INSERT INTO file_events (file_id, event_type, field, old_value, new_value, verified_actor, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + file_id, + record.get("event_type", "file_metadata_update"), + record.get("field", ""), + record.get("old_value", ""), + record.get("new_value", ""), + record.get("verified_actor"), + _normalize_iso_to_utc(record.get("created_at")) or _now_iso(), + ), + ) +``` + +> Note: the file_event import omits `actor` entirely (it always has, pre-existing behavior). Do not add `actor` here — only `verified_actor`, matching the scope of this change. If the existing non-merge branch's value tuple differs from the snippet above, preserve its exact existing columns and only insert `verified_actor` + its `record.get("verified_actor")` in the matching position. + +- [ ] **Step 6: Document the system-NULL writer** + +In `src/filigree/finding_issue_cascade.py`, add a clarifying comment above the `record_reconciliation_debt_comment` INSERT (line ~55) — no behavior change: +```python + # ADR-012: reconciliation-debt is a system-authored cascade write with no + # transport proof (bare conn, system actor). verified_author is left NULL + # intentionally — this is a NEW record, not a restored one. + conn.execute( + "INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)", + (issue_id, actor, f"{RECONCILIATION_DEBT_PREFIX} {text}", _now_iso()), + ) +``` + +- [ ] **Step 7: Run the tests + mypy to verify they pass** + +Run: `uv run pytest tests/core/test_verified_actor.py -k "round_trip" -v && uv run mypy src/filigree/db_meta.py src/filigree/finding_issue_cascade.py` +Expected: PASS; mypy clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/filigree/db_meta.py src/filigree/finding_issue_cascade.py tests/core/test_verified_actor.py +git commit -m "feat: preserve verified_* across export/import; document system-NULL writers (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Entry points — resolve identity (CLI + MCP-stdio) + mismatch warning + +**Files:** +- Modify: `src/filigree/cli_common.py` (`get_db` sets verified actor) +- Modify: `src/filigree/cli.py` (`cli()` group callback emits mismatch warning to stderr) +- Modify: `src/filigree/mcp_server.py` (`_init_db` sets verified actor; `call_tool` logs mismatch) +- Test: `tests/cli/test_verified_actor_cli.py`, `tests/mcp/test_verified_actor_mcp.py` + +- [ ] **Step 1: Write the failing CLI test** + +Create `tests/cli/test_verified_actor_cli.py`. Use the suite's existing CLI runner idiom (check `tests/cli/test_admin_commands.py` for the `CliRunner` + temp-project fixture pattern). Core assertions: +```python +"""CLI transport-bound actor identity tests (ADR-012, schema v24).""" + +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from filigree.cli import cli + + +def _init_project(runner: CliRunner, tmp_path: Path) -> None: + result = runner.invoke(cli, ["init"], catch_exceptions=False) + assert result.exit_code == 0 + + +def test_cli_write_stamps_verified_actor(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + runner = CliRunner() + _init_project(runner, tmp_path) + result = runner.invoke(cli, ["--actor", "alice", "create", "task", "t"], catch_exceptions=False) + assert result.exit_code == 0 + + from filigree.cli_common import get_db + + db = get_db() + row = db.conn.execute("SELECT verified_actor FROM events WHERE event_type = 'created' LIMIT 1").fetchone() + assert row["verified_actor"] == "alice" + + +def test_cli_mismatch_warns_on_stderr_but_does_not_block(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + runner = CliRunner(mix_stderr=False) + _init_project(runner, tmp_path) + result = runner.invoke(cli, ["--actor", "agent-x", "create", "task", "t"], catch_exceptions=False) + assert result.exit_code == 0 # never blocks + assert "ACTOR_MISMATCH" in result.stderr + + +def test_cli_no_warning_for_placeholder_default_actor(tmp_path, monkeypatch) -> None: + # The 'cli' default is a framework placeholder, not a genuine claim — quiet + # even though it differs from the verified OS user (decision 9). + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + runner = CliRunner(mix_stderr=False) + _init_project(runner, tmp_path) + result = runner.invoke(cli, ["create", "task", "t"], catch_exceptions=False) # no --actor → "cli" + assert result.exit_code == 0 + assert "ACTOR_MISMATCH" not in result.stderr +``` + +> Adjust `init` / `create` verbs and argument order to the real CLI surface (check `tests/cli/`). The `resolve_os_actor` monkeypatch target must match the import site actually used by `get_db` / the `cli()` callback — patch where it is *looked up*, not where it is defined (see Step 3/4 for the import choice). + +- [ ] **Step 2: Run the CLI test to verify it fails** + +Run: `uv run pytest tests/cli/test_verified_actor_cli.py -v` +Expected: FAIL — `verified_actor` stored NULL (not set in `get_db`); no `ACTOR_MISMATCH` on stderr. + +- [ ] **Step 3: Set verified actor in `get_db()`** + +In `src/filigree/cli_common.py`, import the resolver at the top of the file (with the other imports): +```python +from filigree import actor_identity +``` +Then in `get_db()`, set the verified actor on the constructed DB before returning. Replace the success arm (lines ~205-208): +```python + try: + if conf_path is not None: + database = FiligreeDB.from_conf(conf_path) + else: + database = FiligreeDB.from_filigree_dir(project_root / FILIGREE_DIR_NAME) + database.set_verified_actor(actor_identity.resolve_os_actor()) + return database +``` + +- [ ] **Step 4: Emit the mismatch warning in the `cli()` group callback** + +In `src/filigree/cli.py`, the `cli()` group callback (line ~81) already has the sanitized claimed `actor`. After `ctx.obj["actor"] = cleaned` (line ~97), add the mismatch check (logs to stderr, never blocks): +```python + ctx.obj["actor"] = cleaned + + # ADR-012: surface a non-blocking warning when the claimed --actor disagrees + # with the transport-verified OS identity. Resolution and the warning never + # raise and never block the command. + from filigree import actor_identity + + _verified = actor_identity.resolve_os_actor() + _warning = actor_identity.actor_mismatch_warning(cleaned, _verified) + if _warning is not None: + click.echo( + f"warning: {_warning['code']} claimed={_warning['claimed']!r} verified={_warning['verified']!r}", + err=True, + ) +``` + +> The CLI resolves `resolve_os_actor()` twice (once here, once in `get_db`); both are best-effort and cheap. If you prefer a single resolution, stash it on `ctx.obj["verified_actor"]` here and have `get_db` read it — but `get_db` has no `ctx` access, so the two-call form is simpler and acceptable. + +- [ ] **Step 5: Run the CLI test to verify it passes** + +Run: `uv run pytest tests/cli/test_verified_actor_cli.py -v` +Expected: PASS. + +- [ ] **Step 6: Write the failing MCP-stdio test** + +Create `tests/mcp/test_verified_actor_mcp.py`. Use the suite's MCP harness (check `tests/mcp/conftest.py` and `tests/mcp/test_tools.py` for how `_init_db` / `call_tool` are exercised). Core assertions: +```python +"""MCP-stdio transport-bound actor identity tests (ADR-012, schema v24).""" + +from __future__ import annotations + +import filigree.mcp_server as mcp_server + + +def test_init_db_sets_verified_actor(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + # Stand up a project + call the server's DB-init entry point. + # (Use the conftest helper that creates a project and calls mcp_server._init_db.) + filigree_dir = _init_project_for_mcp(tmp_path) # provided by tests/mcp/conftest.py + mcp_server._init_db(filigree_dir, None) + assert mcp_server.db is not None + assert mcp_server.db._verified_actor == "alice" + + +async def test_call_tool_injects_actor_mismatch_warning(tmp_path, monkeypatch) -> None: + import json + + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + filigree_dir = _init_project_for_mcp(tmp_path) + mcp_server._init_db(filigree_dir, None) + # A write tool with a genuine, distinct claimed actor → mismatch warning in + # the envelope; the call still succeeds (never blocks). + result = await mcp_server.call_tool("issue_create", {"type": "task", "title": "t", "actor": "agent-x"}) + payload = json.loads(result[0].text) + assert "warnings" in payload + assert any(w["code"] == "ACTOR_MISMATCH" for w in payload["warnings"]) + + +async def test_call_tool_no_warning_for_placeholder_actor(tmp_path, monkeypatch) -> None: + import json + + monkeypatch.setattr("filigree.actor_identity.resolve_os_actor", lambda: "alice") + filigree_dir = _init_project_for_mcp(tmp_path) + mcp_server._init_db(filigree_dir, None) + result = await mcp_server.call_tool("issue_create", {"type": "task", "title": "t", "actor": "mcp"}) + payload = json.loads(result[0].text) + assert "ACTOR_MISMATCH" not in result[0].text # placeholder claim → no warning +``` + +> `_init_db`'s real signature is `_init_db(filigree_dir, conf_path)` (`mcp_server.py:1141`); confirm the exact parameter names/order in situ and match the conftest helper. If the conftest exposes a higher-level "boot the server against tmp project" fixture, use it instead of calling `_init_db` directly. The two `call_tool` tests are `async` — match the suite's async-test convention (`@pytest.mark.asyncio` or the configured `asyncio_mode`; check `tests/mcp/test_tools.py`). Use the canonical tool name and argument shape that suite already exercises (`issue_create` is the namespaced name; if the suite calls the legacy `create_issue`, use that — `call_tool` canonicalizes either). + +- [ ] **Step 7: Run the MCP test to verify it fails** + +Run: `uv run pytest tests/mcp/test_verified_actor_mcp.py -v` +Expected: FAIL — `mcp_server.db._verified_actor` is `None` (never set). + +- [ ] **Step 8: Set verified actor in MCP `_init_db`** + +In `src/filigree/mcp_server.py`, in `_init_db` (line ~1144-1148), after the successful construction, set the verified actor: +```python + try: + db = FiligreeDB.from_conf(conf_path) if conf_path is not None else FiligreeDB.from_filigree_dir(filigree_dir) + from filigree.actor_identity import resolve_os_actor + + db.set_verified_actor(resolve_os_actor()) + _schema_mismatch = None + _registry_startup_error = None + _db_open_error = None +``` + +- [ ] **Step 9: Add the `_inject_warnings` envelope helper** + +In `src/filigree/mcp_tools/common.py` (which already imports `json` and `TextContent`), add a generic warning-injection helper near `_text`: +```python +def _inject_warnings(result: list[TextContent], warnings: list[dict[str, Any]]) -> list[TextContent]: + """Add a top-level ``warnings`` array to a tool's JSON envelope. + + Post-processing hook so warning producers (e.g. ADR-012 actor mismatch) need + not touch every handler. Parses the first text element; if it is a JSON + object, appends to (or creates) its ``warnings`` list. Bare-string and + non-object responses are returned untouched. Never raises. + """ + if not warnings or not result: + return result + first = result[0] + if first.type != "text": + return result + try: + payload = json.loads(first.text) + except (json.JSONDecodeError, ValueError): + return result # bare-string response — leave untouched + if not isinstance(payload, dict): + return result + existing = payload.get("warnings") + payload["warnings"] = (existing if isinstance(existing, list) else []) + warnings + return [TextContent(type="text", text=json.dumps(payload, indent=2, default=str)), *result[1:]] +``` +(Ensure `Any` is imported in that module — it almost certainly already is; if not, add `from typing import Any`.) + +- [ ] **Step 10: Inject the mismatch warning in `call_tool`** + +In `src/filigree/mcp_server.py`, in `call_tool`'s inner `_run` (line ~944), wrap the handler result so a mismatch warning is added to the envelope. Replace the `out = await handler(arguments)` line: +```python + async def _run() -> list[TextContent]: + try: + out: list[TextContent] = await handler(arguments) + # ADR-012: surface a non-blocking actor mismatch in the response + # envelope's ``warnings`` list. Best-effort — never break a tool call. + try: + from filigree.actor_identity import actor_mismatch_warning + from filigree.mcp_tools.common import _inject_warnings + + run_db = _request_db.get() or db + if run_db is not None: + mismatch = actor_mismatch_warning(arguments.get("actor"), run_db._verified_actor) + if mismatch is not None: + out = _inject_warnings(out, [dict(mismatch)]) + except Exception: + pass + return out + except Exception: + if _logger: + _logger.error("tool_error", extra={"tool": name, "args_data": arguments}, exc_info=True) + raise + finally: + # Safety net: roll back any uncommitted transaction left by a + # failed mutation. Re-resolve _get_db() in case the handler + # switched the ContextVar-scoped DB. + resolved = _request_db.get() or db + if resolved is not None and resolved.conn.in_transaction: + resolved.conn.rollback() +``` + +- [ ] **Step 11: Run the MCP + CLI tests + mypy to verify they pass** + +Run: `uv run pytest tests/mcp/test_verified_actor_mcp.py tests/cli/test_verified_actor_cli.py -v && uv run mypy src/filigree/cli_common.py src/filigree/cli.py src/filigree/mcp_server.py src/filigree/mcp_tools/common.py` +Expected: PASS; mypy clean. + +- [ ] **Step 12: Commit** + +```bash +git add src/filigree/cli_common.py src/filigree/cli.py src/filigree/mcp_server.py src/filigree/mcp_tools/common.py tests/cli/test_verified_actor_cli.py tests/mcp/test_verified_actor_mcp.py +git commit -m "feat: resolve+stamp verified actor at CLI/MCP entry points; envelope mismatch warning (ADR-012) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Docs, CHANGELOG, ADR-012, and contract fixtures + +**Files:** +- Modify: `docs/SCHEMA_MIGRATIONS.md` +- Modify: `CHANGELOG.md` +- Modify: ADR-012 source (find with the grep in Step 1) +- Regenerate/verify: contract fixtures under `tests/fixtures/contracts/` + +- [ ] **Step 1: Locate the ADR and contract-fixture generators** + +Run: +```bash +cd /home/john/filigree +grep -rl "ADR-012" docs/ +ls tests/fixtures/contracts/classic tests/fixtures/contracts/loom +grep -rn "verified_actor\|verified_author" tests/fixtures/contracts/ || echo "no fixture hits yet" +grep -rn "regen\|generate" tests/util/test_type_contracts.py | head +``` +Expected: the ADR-012 file path; the fixture layout; whether any fixture already mentions the new columns (it should not yet); how contract fixtures are regenerated (look for a `--regen`/`UPDATE_FIXTURES` env or a generator script referenced by the contract tests). + +- [ ] **Step 2: Add a `## v24` entry to `docs/SCHEMA_MIGRATIONS.md`** + +Match the format of the existing v23 entry. Content: +```markdown +## v24 — Transport-bound actor identity (ADR-012) + +Adds a nullable `verified_*` column to every runtime event-bearing table: +`events.verified_actor`, `file_events.verified_actor`, +`annotation_events.verified_actor`, `comments.verified_author`, +`observations.verified_actor`. + +The `actor`/`author` claimed value is unchanged. `verified_*` holds the +transport-verified identity (the OS user the writing process ran as) or `NULL` +when no transport proof exists — which is the value for every historical row +(no backfill), every unverified surface, and every system/cascade-authored +write. The `events` dedup unique index is **not** extended: `verified_actor` +is attribution metadata, not part of event identity. + +Migration `migrate_v23_to_v24` is additive and idempotent. Backup/restore +(`export-jsonl` / `import-jsonl`) preserves `verified_*` verbatim. +``` + +- [ ] **Step 3: Add a CHANGELOG entry** + +Add under the appropriate 3.0.0 section of `CHANGELOG.md` (match the existing heading; this is part of the 3.0.0 breaking bundle, so note the schema bump): +```markdown +### Added +- Transport-bound actor identity (ADR-012, schema v24): every runtime write now + records a `verified_*` column alongside the claimed `actor`/`author`, holding + the OS-user identity the process verifiably ran as (or `NULL` when no + transport proof exists). A non-blocking `ACTOR_MISMATCH` warning is emitted + when the claimed and verified identities disagree (CLI: stderr; MCP-stdio: + structured log). No backfill; the `events` dedup index is unchanged. +``` + +- [ ] **Step 4: Extend ADR-012** + +Append a "v24 increment" / "Implementation status" subsection to the ADR-012 file located in Step 1, recording: schema v24 columns, session-level `_verified_actor` plumbing, CLI + MCP-stdio resolvers, the record-both-and-warn (never block) conflict policy, and the explicit **out-of-scope** items (MCP-HTTP peer identity, dashboard auth, envelope `warnings` channel — decision 6). + +- [ ] **Step 5: Regenerate contract fixtures (if generation changed their content)** + +The bulk `export_jsonl` uses `SELECT *`, so any contract fixture derived from exported rows or from the TypedDict shapes now includes the new fields. Regenerate per the mechanism found in Step 1 (e.g. an env-gated regen, or a generator under `src/filigree/generations/`). Then: + +Run: `uv run pytest tests/util/test_type_contracts.py tests/util/test_docs_contracts.py tests/test_error_envelope_contract.py -v` +Expected: PASS. If a contract test fails because a fixture is stale, regenerate it (do not hand-edit generated fixtures unless the test contract says to), confirm the diff only adds `verified_*`, and re-run. + +- [ ] **Step 6: Commit** + +```bash +git add docs/SCHEMA_MIGRATIONS.md CHANGELOG.md docs/ tests/fixtures/contracts/ +git commit -m "docs: document v24 verified-actor schema (SCHEMA_MIGRATIONS, CHANGELOG, ADR-012); refresh contract fixtures + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification (full gate before declaring done) + +- [ ] **Step 1: Run the full CI pipeline** + +Run: +```bash +cd /home/john/filigree +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run mypy src/filigree/ +uv run pytest --tb=short +``` +Expected: all green. (No JS touched, so the biome gate does not apply.) + +- [ ] **Step 2: Confirm the schema-version + migration round-trip end to end** + +Run: `uv run pytest tests/core/test_schema.py tests/core/test_verified_actor.py tests/core/test_actor_identity.py tests/cli/test_verified_actor_cli.py tests/mcp/test_verified_actor_mcp.py -v` +Expected: all PASS. + +- [ ] **Step 3: Spot-check no system writer regressed to a non-NULL stamp** + +Run: `uv run pytest tests/core/test_observations.py tests/core/test_annotations.py tests/cli/test_admin_commands.py -q` +Expected: PASS (import/export, observation, annotation paths unaffected except for the additive column). + +--- + +## Spec coverage map (self-review) + +| Spec section | Covered by | +|---|---| +| §3 schema v24 — 5 tables, version bump, CREATE + migration, dedup index untouched | Task 1 | +| §3 `observation_actor` left as-is; `actor`→`verified_actor` pairing | Task 6 (only `observations.actor` paired; `observation_actor` on `observation_links` is out of scope, unchanged) | +| §4 session plumbing — `__init__`, setter, `borrow_for_worker_thread` propagation | Task 3 | +| §4 every runtime insert stamps `self._verified_actor` | Tasks 4, 5, 6 | +| §5 `actor_identity.py` resolver; CLI + MCP-stdio set it | Tasks 2, 8 | +| §5 Windows-safe resolver (`None`, no crash) | Task 2 | +| §6 mismatch warning at entry point, never blocks; shared helper; envelope `warnings` list | Tasks 2, 8 (CLI=stderr; MCP=envelope `warnings` via `_inject_warnings`, decision 6; placeholder claims suppressed, decision 9) | +| §6 read path — `EventRecord` + comment/observation projections; historical → null | Tasks 4, 5, 6 | +| §7 insert sites (events, file_events×2, annotation_events, observations, comments×2) | Tasks 4, 5, 6 | +| §7 db_meta/cascade per-site decision; migrations excluded | Task 7 (import = preserve; cascade/migrations = NULL) | +| §8 testing items 1–10 | Tasks 1 (1,2,10-schema), 3 (5), 4 (3,8), 6, 7, 8 (3,4,6), 9 (10-docs/fixtures); item 9 (resolver robustness) → Task 2 | +| §9 files touched | All tasks; **plus `db_base.py`** (decision 2, Task 3) | +| §10 non-goals (no HTTP/dashboard auth, no blocking, no backfill, no dedup-index change) | Honored throughout; decision 6 records the HTTP deferral explicitly | diff --git a/docs/superpowers/specs/2026-06-03-loom-bearer-token-auth-design.md b/docs/superpowers/specs/2026-06-03-loom-bearer-token-auth-design.md index 14655a8b..8e14f952 100644 --- a/docs/superpowers/specs/2026-06-03-loom-bearer-token-auth-design.md +++ b/docs/superpowers/specs/2026-06-03-loom-bearer-token-auth-design.md @@ -5,6 +5,13 @@ **Tracking issue:** `filigree-30cd35bcb9` (Reconcile inbound auth posture across Filigree↔Clarion — token sent, ignored) **Related:** `filigree-81d3971467` (transport-bound *identity* — stays open), ADR-012 (actor-identity threat model), ADR-002 (API generations / federation posture) +> **Amendment (3.0.0, 2026-06-07, `filigree-0e4bc3d81a`):** the env var named +> `FILIGREE_API_TOKEN` throughout this spec was renamed to **`WEFT_FEDERATION_TOKEN`** +> (federation plumbing takes the Weft prefix). `FILIGREE_FEDERATION_API_TOKEN` and +> `FILIGREE_API_TOKEN` are now deprecated, backward-compatible fallbacks +> (read order: `WEFT_FEDERATION_TOKEN` → `FILIGREE_FEDERATION_API_TOKEN` → +> `FILIGREE_API_TOKEN`; removal post-1.0). Names below are historical. + ## Problem Clarion's Filigree HTTP client sends `Authorization: Bearer ` diff --git a/docs/superpowers/specs/2026-06-05-governed-cascade-close.md b/docs/superpowers/specs/2026-06-05-governed-cascade-close.md new file mode 100644 index 00000000..443937d5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-governed-cascade-close.md @@ -0,0 +1,164 @@ +# Governed finding→issue cascade close — design & decision + +**Date:** 2026-06-05 · **Status:** Decided (Design A) · **Context:** PR #52 review finding "Legis H-02" (review M-/H-02); umbrella plan `/tmp/filigree-3.0.0-remediation-plan.md`. + +## Problem + +When a scan finding resolves (`fixed` / `unseen_in_latest`), the finding→issue +cascade auto-closes the linked issue. If that issue is **governed** — it carries +a Legis sign-off, i.e. an `entity_associations` row with a non-null `signature` +(v25, B1) — the close must respect the Legis **closure gate**. Today it does +not: `_close_issue_for_fixed_finding_tx` (`db_files.py:1931`) closes with +`force=True` and never consults Legis. A governed issue is auto-closed on a +scanner's say-so, bypassing governance. + +### Corrected premise (the review got this wrong) + +The PR #52 review and umbrella-plan v1/v2 asserted that fixing this is +"invasive" because "the cascade runs inside the writer transaction and the data +layer must not make the Legis network call there." **That premise is false for +the close path.** Verified against `db_files.py`: + +- The close cascade runs **post-commit, outside the ingest transaction** + (`db_files.py:1519-1544`; the comment at 1493/1519 says "Post-commit … Runs + OUTSIDE the ingest transaction"). +- The retention/age-out path is the same: `@_in_immediate_tx` sits on the inner + `_sweep_stale_findings_to_fixed` (`:2003-2051`); the public + `clean_stale_findings` (`:2053`) runs the sweep, commits, then loops the close + cascade post-commit (`:2069-2075`). +- The shared wrapper `_close_issue_for_fixed_finding` (`:1922`) carries **no** + transaction decorator; only the inner `_close_issue_for_fixed_finding_tx` + (`:1930`) opens its own `BEGIN IMMEDIATE` per close. + +So **both** callers reach the close helper post-commit, outside any enclosing +transaction. A Legis network call placed in the wrapper violates no +transaction boundary. This collapses the cost gap between the two designs. + +## Decision + +**Adopt Design A** (consult the gate; Legis may approve a cascade auto-close of +a governed issue). **Design B is specified below and deferred** as the +zero-network degradation mode / future fallback. + +Rationale: with the post-commit correction, A costs essentially the same as B +to build (B's debt machinery + one `evaluate_closure_gate` call + an +approved-close branch), and it gives the better product behaviour — genuinely +resolved governed issues still auto-close when Legis approves, instead of +piling up as manual reconciliation debt. B remains the right design **if** A's +synchronous-network cost (below) becomes unacceptable. + +--- + +## Design A — consult the gate in the post-commit cascade (CHOSEN) + +Place the governance check in the shared wrapper `_close_issue_for_fixed_finding` +(`db_files.py:1922`), before delegating to the transactional close. Because +`evaluate_closure_gate` (`governance.py:72`) already short-circuits cheaply — +`NOT_CONFIGURED` when `LEGIS_URL` is unset, `PROCEED` for an ungoverned issue +(no associations with a signature), both **without a network call** — only a +governed issue in a Legis-configured deployment incurs the network round-trip. + +Flow per resolved finding (post-commit, both callers): +1. `decision = evaluate_closure_gate(self, issue_id)`. +2. `decision.allowed` → `_close_issue_for_fixed_finding_tx(...)` (existing close; + re-validates finding-still-resolved / no-open-sibling / not-already-terminal + under the writer lock). +3. **blocked / unreachable / integrity-failure** → do **not** close; record + **idempotent** reconciliation debt, append to `stats["warnings"]`, return + `False`. (This is Design B's behaviour, as A's degraded branch.) + +Properties: +- **No `is_governed` helper needed.** `evaluate_closure_gate` encapsulates the + governed/ungoverned/unconfigured logic. (The architecture review's + "extract a shared predicate into governance.py" was a Design-B artifact and + evaporates here — do not port it.) +- **The reopen cascade is intentionally NOT gated.** Governance gates *closure*, + not reopen; a regressed finding reopening a governed issue is correct and + needs no Legis approval. Leave `_reopen_issue_for_regressed_finding` untouched. +- **Idempotency required on the blocked branch.** A persistently-blocked + governed issue is re-evaluated every ingest/sweep; the debt write + (`finding_issue_cascade.record_reconciliation_debt_comment`, `:58-60`, a plain + INSERT) must become idempotent (guard on an existing same-issue debt row, or + `INSERT OR IGNORE` against `(issue_id, author, text)`), else the debt comment + accumulates per run. An *approved* close is terminal and never re-triggers, so + only blocked issues churn — less than under B (where all governed issues do). +- **Observability reuses existing wiring.** The post-commit loops already lift + cascade warnings to `stats["warnings"]` and `logger.warning` + (`db_files.py:1532-1539`; age-out `:2076-2077`). The blocked-close warning + flows through the same path — no new surface. + +### A's primary cost (and why B exists) + +A adds a **synchronous Legis call on the ingest/sweep hot path**, per governed +close, with `legis_client` defaulting to a **5 s timeout** +(`DEFAULT_TIMEOUT_SECONDS`). If Legis is slow or down and a batch resolves *N* +governed findings, the naive loop incurs up to *N × 5 s* of serial timeouts in +the post-commit phase, hanging the scan-ingest response. + +**Mitigation (ship with A):** batch short-circuit. Once any gate call returns +`UNREACHABLE`/`INTEGRITY_FAILURE`, treat the remainder of the batch as +deferred-debt **without** re-calling Legis. Implement by having the wrapper +report a distinguishable "unreachable" outcome and the orchestration loop carry +a `legis_down` flag that skips further gate calls for the rest of that batch. +This bounds the worst case to a single timeout per batch. + +This synchronous-network cost on the ingest hot path is precisely **why Design B +is retained**: B is the zero-network degradation mode. If the latency or the +operational coupling of A to Legis availability becomes unacceptable, switch the +gate branch to "treat all governed as deferred-debt" (Design B) — the debt +machinery, the list surface, and the post-commit structure are identical, so the +switch is a one-branch change. + +--- + +## Design B — refuse governed cascade closes (DEFERRED · future fallback) + +**Behaviour:** the cascade never consults Legis. In the post-commit close +helper, detect governed locally (any associated row has a `signature` — the same +cheap read `evaluate_closure_gate` does first, with the same `is_configured()` +guard so a non-Legis deployment still auto-closes) and **refuse** the auto-close, +recording idempotent reconciliation debt. A governed issue is never auto-closed +by a scan; closing it requires the explicit, already-gated close surface +(`dashboard_routes/issues.py`, `mcp_tools/issues.py`, `cli_commands/issues.py`), +which *does* call `evaluate_closure_gate`. + +**Pros:** zero network on the ingest hot path; no coupling of scan-ingest +latency to Legis availability; strictly fail-closed; simplest possible code. + +**Cons:** genuinely-resolved governed issues do not auto-close — they accrue as +reconciliation debt requiring an explicit gated close. Debt volume tracks the +*count of governed issues*, not Legis-downtime events, so a project that governs +many issues generates steady manual toil. (Per the systems review, that toil is +also a signal that governance may be applied too broadly.) + +**When to switch from A to B:** if A's synchronous Legis call on the ingest path +causes unacceptable latency, flakiness, or an availability coupling that +operators reject. Because A and B share the post-commit structure, the idempotent +debt write, the reconciliation-debt list surface, and the warnings/log wiring, +the switch is changing one branch in `_close_issue_for_fixed_finding` from +"call the gate, close if allowed, else debt" to "if governed, debt; else close." + +**Implementation note for B (if ever taken):** B *does* need the +`is_governed(reader, issue_id)` predicate (governed AND `is_configured()`) +extracted into `governance.py` — the architecture review's placement constraint +applies (the `_AssocReader` Protocol at `governance.py:59-64` forbids putting it +in the db layer with governance importing it). A does not need this. + +--- + +## Shared between A and B (already required, ship regardless) + +- **Idempotent reconciliation-debt write** (`finding_issue_cascade.py:58-60`). +- **Reconciliation-debt list surface** (umbrella B5): a cross-issue read verb + (CLY + MCP) that lists issues carrying debt, discriminating on + `author = 'filigree:reconciliation'` (`RECONCILIATION_DEBT_ACTOR`, + `finding_issue_cascade.py:22`) — **not** a `LIKE '[reconciliation-debt]%'` + scan on the unindexed `comments.text`. +- A *retry/sweep* verb (re-attempt deferred closes) is a 3.1.0 follow-up under + both designs; under A it re-runs the gate, under B it routes through the + explicit gated close. + +## Implementation + +See `docs/plans/2026-06-05-governed-cascade-close-design-a.md` for the TDD task +breakdown. Tracking: epic + B2/B5 child issues (filed 2026-06-05). diff --git a/docs/superpowers/specs/2026-06-05-transport-bound-actor-identity-design.md b/docs/superpowers/specs/2026-06-05-transport-bound-actor-identity-design.md new file mode 100644 index 00000000..e805d34c --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-transport-bound-actor-identity-design.md @@ -0,0 +1,149 @@ +# Design: Transport-bound actor identity (v24 slice) + +**Ticket:** `filigree-81d3971467` — Transport-bound actor identity verification +**Branch:** `release/3.0.0` (commits land directly on the branch) +**ADR:** extends ADR-012 (Actor Identity Threat Model) +**Date:** 2026-06-05 +**Scope of this increment:** schema (v24) + CLI OS-user verification + MCP-stdio +parent attribution. **Out of scope (follow-up tickets):** MCP-HTTP peer identity, +HTTP dashboard session/token/mTLS auth. + +## 1. Problem + +Per ADR-012, the `actor` string on every Filigree write is an *unauthenticated +claim, not a proof*. 2.1.0 §1.4 capped its length and rejected control chars at +every entry point but never bound it to the transport. The audit trail therefore +cannot distinguish "agent X says it did this" from "the process verifiably +running as X did this." This is the keystone schema change (v24) that the other +3.0.0 breaking tickets build on. + +## 2. Decisions (locked in brainstorming) + +| # | Decision | +|---|----------| +| Scope | Schema + CLI + MCP-stdio. Defer MCP-HTTP and dashboard auth. | +| Schema | Nullable `verified_*` column on **all runtime event-bearing tables**. `actor`/`author` stay as the claimed value — no rename, no backfill. `NULL` = no transport proof (all historical rows + unverified surfaces). | +| Plumbing | **Session-level**: `FiligreeDB` carries `self._verified_actor: str \| None`, set once at the entry point. No per-call kwarg, no public-signature churn. | +| Conflict | **Record both + warn on mismatch.** Claimed value stored as given; verified value stored alongside; a structured warning is emitted when claimed ≠ verified. Never block. | +| Column naming | Mirror the existing claimed column: `verified_author` on `comments`, `verified_actor` elsewhere. | + +## 3. Data model — schema v24 + +`CURRENT_SCHEMA_VERSION` 23 → 24. Migration `_migrate_v23_to_v24` performs, per +table, `ALTER TABLE ADD COLUMN verified_ TEXT` (nullable, no default — +existing rows read `NULL`): + +| Table | Claimed column | New column | +|-------|----------------|-----------| +| `events` | `actor` | `verified_actor` | +| `file_events` | `actor` | `verified_actor` | +| `annotation_events` | `actor` | `verified_actor` | +| `comments` | `author` | `verified_author` | +| `observations` | `actor` (+ `observation_actor`) | `verified_actor` | + +The base `db_schema.py` `CREATE TABLE` statements gain the column too (fresh DBs). +The dedup unique index on `events` (`issue_id, event_type, actor, …`) is **not** +extended — `verified_actor` is attribution metadata, not part of event identity. +Migration-time inserts (`migrations.py`, `migrate.py`) and other system writers +leave the column `NULL`; only runtime user writes stamp it. + +> Open confirmation during implementation: `observations` also has an +> `observation_actor` column. The claimed actor for an observation is `actor`; +> `verified_actor` pairs with it. `observation_actor` (the promoting actor) is +> left as-is unless a test shows it is the relevant identity. + +## 4. Session-level identity plumbing + +- `FiligreeDB.__init__` gains `verified_actor: str | None = None`, stored as + `self._verified_actor`. A setter `set_verified_actor(value)` allows entry points + that construct the DB before resolving identity. +- `borrow_for_worker_thread` clones **propagate** `self._verified_actor` (loom + HTTP worker-thread connections must keep the same verified identity). +- Every runtime event/comment/observation insert includes `self._verified_actor`. + `_record_event` (events) is the primary chokepoint; `file_events`, + `annotation_events`, `comments`, `observations` insert sites each read the same + instance field. (Insert sites enumerated in §7.) + +## 5. Verification resolvers + +A small helper module `actor_identity.py`: + +``` +def resolve_os_actor() -> str | None: + """Best-effort OS-user identity, or None on any failure.""" + try: + import os, pwd + return pwd.getpwuid(os.geteuid()).pw_name or None + except Exception: + return None +``` + +- **CLI** (`cli.py` / `cli_common.py`): after building the `FiligreeDB`, call + `db.set_verified_actor(resolve_os_actor())` before dispatching the verb. +- **MCP-stdio** (`mcp_server.py`): the stdio server process runs as an OS user; + set `db.set_verified_actor(resolve_os_actor())` on the runtime DB during + context setup. (Windows lacks `pwd`; `resolve_os_actor` returns `None` → + `verified_actor` stays `NULL`, no crash.) + +## 6. Mismatch warning + read path + +- **Mismatch detection happens at the entry point, not per-insert.** Low-level + insert sites only *stamp* `self._verified_actor`; they do not raise warnings + (they cannot reach the response envelope). The CLI dispatch / MCP tool wrapper + already knows both the claimed `actor` it is about to use and the resolved + verified identity, so it performs one check per call: if both are non-empty and + differ, emit a structured warning + `{"code": "ACTOR_MISMATCH", "claimed": , "verified": }` — + CLI logs to stderr; MCP/HTTP add it to the response envelope `warnings` list. + Never raises. A shared helper `actor_mismatch_warning(claimed, verified)` keeps + CLI and MCP consistent. +- **Read path**: `EventRecord` TypedDict (`types/events.py`) gains + `verified_actor: str | None`; `_build_event_record(_with_title)` projects the + new column. Comment/observation read projections gain the mirrored field. + Historical rows surface `null`, making "claim-only" events visible so reviewers + do not assume retroactive verification. + +## 7. Insert sites to update (verified by grep) + +- `db_events.py:_record_event` → `events` +- `db_files.py:286,567` → `file_events` +- `db_annotations.py:562` → `annotation_events` +- `db_observations.py:369` (`observations`), `:840` (`comments`) +- `db_meta.py` / `finding_issue_cascade.py` comment inserts: stamp when the writer + is a runtime path; system/cascade inserts may legitimately leave `NULL` + (decided per-site during implementation, documented in the commit). +- **Excluded** (system, not user): `migrations.py`, `migrate.py`. + +## 8. Testing (TDD, full gate) + +1. **Migration**: a v23 DB upgrades to v24; columns exist; pre-existing rows read + `verified_actor IS NULL`; row data preserved. +2. **Fresh schema**: a new DB has the columns at v24. +3. **CLI write**: stamps `verified_actor = resolve_os_actor()` on the event. +4. **MCP-stdio write**: stamps verified identity. +5. **Session propagation**: `borrow_for_worker_thread` clone keeps the identity. +6. **Conflict**: claimed == verified → no warning; claimed ≠ verified → both + stored + `ACTOR_MISMATCH` warning emitted. +7. **Unverified surface**: with no resolver set, writes store `verified_actor = + NULL`, no warning. +8. **Read path**: `EventRecord` exposes `verified_actor`; historical rows → `None`. +9. **Resolver robustness**: `resolve_os_actor` returns `None` (not raise) when + `pwd` is unavailable. +10. Schema-version test + contract fixtures updated; `SCHEMA_MIGRATIONS.md` notes + v24. + +## 9. Files touched + +**Update:** `db_schema.py` (CREATE + `CURRENT_SCHEMA_VERSION`), `migrations.py` +(new `_migrate_v23_to_v24`), `core.py` (`FiligreeDB.__init__` + setter + +`borrow_for_worker_thread`), `db_events.py`, `db_files.py`, `db_annotations.py`, +`db_observations.py`, `types/events.py` (+ comment/observation read types), +`cli.py`/`cli_common.py`, `mcp_server.py`, `docs/SCHEMA_MIGRATIONS.md`, CHANGELOG. +**Create:** `actor_identity.py`, tests, possibly extend `ADR-012`. + +## 10. Non-goals + +- No HTTP-MCP peer identity, no dashboard auth (separate tickets). +- No blocking on mismatch; no rejection of unverified writes. +- No retroactive backfill of `verified_actor` on historical rows. +- No change to the `events` dedup identity index. diff --git a/docs/superpowers/specs/2026-06-06-signature-bypass-resolution.md b/docs/superpowers/specs/2026-06-06-signature-bypass-resolution.md new file mode 100644 index 00000000..d5f5dd99 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-signature-bypass-resolution.md @@ -0,0 +1,169 @@ +# Resolution: governed→ungoverned bypass via the signature field + +**Status:** IMPLEMENTED on release/3.0.0 (owner-approved). Schema v27. +**Repro:** `tests/core/test_governance_signature_bypass.py` (now green). +**Date:** 2026-06-06 + +## Implementation summary (as built) + +Both vectors closed; resolution (b) is **sticky governance + fail-closed-on-drift**. +Three refinements emerged from the pre-implementation scout+critique pass and +were adopted over the original sketch: + +1. **Distinct `GateOutcome.STALE`** (not reused `UNAVAILABLE`). The finding cascade + short-circuits the whole batch on the first `UNAVAILABLE` (treats it as + "Legis down"); a per-issue drift verdict must not suppress Legis for the rest + of the batch. STALE renders as `CONFLICT` (409) on every surface (the + renderers already collapse non-PROCEED/non-INTEGRITY to CONFLICT) and flows + to reconciliation debt via the cascade's existing non-PROCEED path. +2. **MCP stays signature-preserving** (option b): only Legis signs, via the HTTP + binding route; an agent's MCP re-attach preserves the existing sign-off and a + drift surfaces as STALE until Legis re-signs. No MCP schema change. +3. **Debt recording stays caller-side** (cascade only); the gate remains + side-effect-free. Direct dashboard/MCP/CLI closes render STALE as a 409; the + "record debt" line applies to the cascade path only. + +Edit set: `db_schema.py` (column + v27), `migrations.py` (`migrate_v26_to_v27` ++ backfill), `db_entity_associations.py` (`_normalise_optional_signature`, +sticky-CASE UPSERT, `signed_content_hash` through TypedDict/serializer/3 SELECTs), +`governance.py` (`STALE` + `_signed_row_is_stale` + is-not-None predicate), +`db_meta.py` (import threads the column), `mcp_tools/entities.py` + +`finding_issue_cascade.py` (docstrings). Tests: bypass repro now green; new +drift/legacy/mixed/real-DB gate tests; flipped the "documented clobber" test to +sticky; migration + round-trip + schema-version tests. + +--- + +## Original analysis (decision record) + +## Two contract facts that decide this + +**Fact 1 — the signature is an HMAC over the content.** It is +`HMAC({issue_id, entity_id, content_hash, signoff_seq}, legis_key)` +(rebrand inventory, `…rebrand-inventory.md:123`, attributed to the user). The +sign-off is **cryptographically bound to a specific content snapshot.** When the +content drifts `h1→h2`, the stored signature is *provably* a signature over `h1` +— not "maybe stale," demonstrably not a signature over `h2`. + +**Fact 2 — Filigree is a router, not a verifier or an adjudicator.** +- It never verifies the signature — "it has no key; Legis owns governance" + (`db_schema.py:430-433`, `legis_client.py:6`). +- The closure-gate call sends Legis **only the issue id** — + `GET {LEGIS_URL}/filigree/issues/{issue_id}/closure-gate` (`legis_client.py:94`). + The signature, content hash, and signoff_seq are **never transmitted.** +- The gate re-consults Legis live on every close for governed issues + (`governance.py:91`). + +So in Filigree the `signature` column is a local **"should I call Legis?" +routing flag** — but Fact 1 means its *presence over the right content* is the +property that matters, and Filigree **can see** when that property breaks: +at re-attach it holds both `h1` and `h2` and already records the delta +(`db_entity_associations.py:236-240`; `test_reattach_records_refresh_audit_event`). + +> **Correction to an earlier draft of this memo.** A prior version claimed +> "Filigree cannot detect drift, so adjudication is necessarily Legis's." That +> is false — see the re-attach event above. The false premise made +> *preserve-the-signature* look safe. It is not (see Decision (b)). + +## Federation posture (who owns what) + +| Component | Role re: governance | +|---|---| +| **Legis** | Sole governance authority. Only signer (holds the key). Sole adjudicator. The HMAC binds sign-off to content. | +| **Filigree** | Work-state authority. Routes governed closes to Legis. Must never *make or unmake* governance, and must not let a drifted sign-off pass as valid. | +| **Loomweave** | Entity-identity + content-hash authority. The **drift source**. Read-only to governance. | +| **Wardline** | Assurance / finding producer. Read-only to Legis signatures. | + +Cardinal rule the bypass violates: **only Legis confers governance, over +specific content.** Today a routine agent drift-refresh (a work-state op, +Loomweave-driven) silently revokes it. + +## Decision + +### Vector (a) — empty-string signature — FIX NOW (release gate) +`""` is a non-null value the routing flag misreads as "don't ask," contradicting +DECISION 1A ("governed = non-null signature"). Fix at the boundary **and** the +predicate: +- Normalize `signature = signature or None` at every write boundary + (`dashboard_routes/entities.py`, `mcp_tools/entities.py`, + `add_entity_association`) → column is strictly `{real-signature | NULL}`. +- Align the read to `is not None` (`governance.py:89`). + +Unambiguous bug; both changes cheap. + +### Vector (b) — signatureless re-attach of a governed binding — sticky governance + **fail-closed on drift** +Split the case by whether the content hash actually changed (Filigree knows this +at re-attach): + +- **Unchanged hash** (idempotent no-op refresh): **preserve** the signature. + Nothing drifted; still governed at the same content. Robust, trivial. +- **Changed hash** (genuine drift of a governed binding): the existing signature + is now an HMAC over the *old* content (Fact 1) — invalid for the new content. + Keep the issue **governed** (do not silently de-govern — that's the bug) **but + mark it pending-reverification so the close gate FAILS CLOSED** + (`UNAVAILABLE`/reconciliation-debt) until **Legis re-signs over the new hash.** + +**Why not the cheap "preserve on absence" (2-line CASE-mirror) alone.** It keeps +a signature that is *cryptographically known to be wrong* for the current +content, and — because the gate call is issue-id-only — Legis cannot see the +drift from Filigree's request. Its correctness then hinges entirely on an +**unverified** assumption: that Legis independently learns the new content hash +(from Loomweave) and re-derives the HMAC to BLOCK. If that channel does not +exist, preserve-alone **launders a stale close through a "Legis approved" stamp** +— strictly a fail-open, and worse than today's clobber because it *suppresses* +the "needs review" signal instead of just dropping oversight. We have **no +evidence** that Loomweave→Legis drift channel exists; it lives in a repo not +visible here. **Do not bet the gate on it.** + +Fail-closed-on-drift closes the hole **regardless of Legis internals** and is +faithful to Fact 1. It preserves the idempotent-refresh contract Loomweave +depends on (the refresh *succeeds*, the hash updates) — it only refuses to let a +*drifted* governed issue *close* on a stale sign-off. + +**Implementation sketch (one option, not prescribed).** A nullable +`signed_content_hash` column (v26): set on signed writes; on a signatureless +re-attach, preserve it while `content_hash_at_attach` advances. Gate logic: +governed = `signature IS NOT NULL`; *fresh* = `signed_content_hash` matches +current content (or is NULL for legacy rows) → consult Legis; *stale* = +mismatch → fail-closed `UNAVAILABLE` "awaiting Legis re-sign." Reuses the +existing `UNAVAILABLE` outcome and reconciliation-debt surface. + +**Reject:** *clobber-to-NULL* (silent any-agent de-governance — the bug); +*preserve-alone* (laundered fail-open, see above); *block-the-refresh* (breaks +Loomweave's idempotent drift refresh). + +### The two repro tests partition the fix space +- `test_empty_string` → green under predicate-fix **or** preserve. +- `test_signatureless` (NULL/MCP) → green only under sticky-governance fixes; a + predicate change alone leaves it red. +- A *third* test should be added for **drift** (governed `h1` → signatureless + re-attach at `h2` → close must NOT proceed without a fresh Legis sign-off). + Preserve-alone would pass `test_signatureless` but **fail** this drift test — + which is the whole point. + +## The open question the owner must resolve (decision-critical, not additive) +**Does Legis re-adjudicate content drift?** i.e. on the issue-id-only gate call, +does Legis independently know the current content hash (via Loomweave) and BLOCK +when the stored sign-off no longer covers it? +- **If yes:** preserve-alone is *sufficient* (Legis catches it). Fail-closed + local is then belt-and-suspenders. +- **If no / unknown:** fail-closed-on-drift in Filigree is **required** — it is + the only option that closes the hole without trusting an unverified channel. + +Verify against Legis before sign-off. Until verified, recommend fail-closed. + +## North star (post-3.0.0) +- Key governed-ness off **`signoff_seq` monotonicity** (inherently sticky) + rather than signature-presence. +- Give Legis an **explicit revoke verb**; never rely on a NULL write through a + work-state surface to remove governance. + +## Provenance caveat +The "documented/intentional governed→ungoverned flip" comment +(`db_entity_associations.py:181-184`) should be treated as **suspect**, not +authoritative — it rests on the assumption that re-attachers carry the signature +forward, which the actual surfaces (MCP can't supply one; Legis omits when no +key) contradict. Several governance decisions in this area read as post-hoc +rationalizations of scope ("ship the refresh, declare the flip intentional"). +This resolution is re-derived from the contract (Facts 1–2), not from the +inherited comments. diff --git a/mkdocs.yml b/mkdocs.yml index 76bcc517..660b865b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,26 +1,34 @@ site_name: Filigree site_description: Local-first issue tracker designed for AI coding agents — SQLite, MCP tools, no cloud, no accounts. site_url: https://filigree.foundryside.dev -repo_url: https://github.com/tachyon-beep/filigree -repo_name: tachyon-beep/filigree +repo_url: https://github.com/foundryside-dev/filigree +repo_name: foundryside-dev/filigree edit_uri: edit/main/docs/ theme: name: material custom_dir: overrides palette: - - scheme: default - primary: amber - accent: amber - toggle: - icon: material/brightness-7 - name: Switch to dark mode + # Dark-first: slate is listed first, so it is the default scheme. primary/ + # accent are `custom` so Material paints no colour of its own — the vendored + # weft-mkdocs.css owns --md-primary/--md-accent (= the suite's dyed-amber + # accent); the Filigree identity layer (extra.css) repaints links/accent to + # the sky thread on Filigree pages. - scheme: slate - primary: amber - accent: amber + primary: custom + accent: custom toggle: icon: material/brightness-4 name: Switch to light mode + - scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Bundled JetBrains Mono + Space Grotesk (vendored in stylesheets/fonts/ via + # weft-mkdocs.css) own typography now — disable Material's Google Fonts. + font: false features: - navigation.sections - navigation.top @@ -40,6 +48,9 @@ markdown_extensions: alternate_style: true extra_css: + # Shared Weft Federation docs theme (vendored) first, Filigree identity layer + # on top. + - stylesheets/weft-mkdocs.css - stylesheets/extra.css exclude_docs: | @@ -67,13 +78,15 @@ nav: - MCP server: mcp.md - Python API: api-reference.md - Federation: + - Filigree in the Weft Federation: federation/index.md - Contracts: federation/contracts.md - Registry backend runbook: federation/registry-backend-launch-runbook.md - Migrating & upgrading: + - 3.0 consumer migration: MIGRATION-3.0.md - From Beads: MIGRATION.md - Upgrading: UPGRADING.md - Schema migrations: SCHEMA_MIGRATIONS.md - About: - - Changelog: https://github.com/tachyon-beep/filigree/blob/main/CHANGELOG.md - - Contributing: https://github.com/tachyon-beep/filigree/blob/main/CONTRIBUTING.md - - License: https://github.com/tachyon-beep/filigree/blob/main/LICENSE + - Changelog: https://github.com/foundryside-dev/filigree/blob/main/CHANGELOG.md + - Contributing: https://github.com/foundryside-dev/filigree/blob/main/CONTRIBUTING.md + - License: https://github.com/foundryside-dev/filigree/blob/main/LICENSE diff --git a/overrides/home.html b/overrides/home.html index 82241aee..b3106456 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -3,20 +3,21 @@ {# Filigree product landing page. Applied only to docs/index.md via front-matter `template: home.html`. - The hero, feature cards, and Loom-suite grid are defined here; the markdown - body of index.md (install + quick-start) is rendered via {{ page.content }} - so Material's syntax highlighting and content.code.copy buttons keep working - on the real fenced code blocks. + The hero, feature cards, and Weft Federation roster are defined here; the + markdown body of index.md (install + quick-start) is rendered via + {{ page.content }} so Material's syntax highlighting and content.code.copy + buttons keep working on the real fenced code blocks. #} {% block content %}
+

Weft Federation · work-state surface

Filigree

A local-first issue tracker built for AI coding agents. SQLite on disk, - 114 MCP tools for agents, a full CLI for humans — no cloud, no + 116 MCP tools for agents, a full CLI for humans — no cloud, no accounts, no scraping log output.

@@ -34,7 +35,7 @@

Filigree

Agents as first-class citizens

- 114 mcp__filigree__* tools let coding agents create, claim, + 116 mcp__filigree__* tools let coding agents create, claim, and close work natively — no parsing CLI text. A pre-computed context.md orients every session at startup.

@@ -58,7 +59,7 @@

Built for many agents at once

Real workflow, not just a list

- 24 issue types across 9 packs with enforced state machines, a + 24 issue types across 9 workflow packs with enforced state machines, a dependency graph with ready-queue and critical-path analysis, and a live web dashboard with Kanban and graph views.

@@ -71,37 +72,99 @@

Real workflow, not just a list

{{ page.content }}
-
-

The Loom suite

-

- Filigree is one of four Loom citizens — agent-first tooling built on - “humans on the loop, not in the loop.” Each is zero-config and - opt-in: enterprise-class for one-to-two-developer teams, without enterprise - weight. +

+

The Weft Federation

+

+ Filigree is the work-state surface of the Weft + Federation — an agent-first family of small, local-first developer + tools. Each member is authoritative for one domain, useful on its own, + and enrich-only, never load-bearing when composed: removing a + sibling never breaks another's core flow. Filigree owns issues, their + dependencies and state machines, and hosts the named weft + HTTP generation several siblings use as transport. + Read how Filigree engages each member →

- + + + Adjacent — not a member + + Lacuna is the deliberately-flawed demonstration + specimen the whole federation is run against, not a member of + it — point the Weft tools at it and they pick up its seeded bugs. + + + +

+ + Explore the federation hub → + + — doctrine, the SEI standard, and the integration matrix. +

diff --git a/overrides/partials/copyright.html b/overrides/partials/copyright.html index 7a3f8bee..1f205db1 100644 --- a/overrides/partials/copyright.html +++ b/overrides/partials/copyright.html @@ -1,15 +1,21 @@ {#- Filigree override of Material's copyright partial. - Adds the Loom suite links to the footer site-wide so suite presence is - reachable from every docs page, then preserves Material's default content. + Adds the Weft Federation links to the footer site-wide so federation presence + is reachable from every docs page, then preserves Material's default content. + + Loomweave's repository is still named `clarion`. Sibling members link to their + GitHub repos (the federation hub's own convention — only Wardline's doc + subdomain is currently live, and roster/footer stay uniform on repos for it); + Filigree self-references its own live doc site. -#}